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,267 @@
|
|
|
1
|
+
# ODSL — Background Adapters, Audit System & Distributed Locking
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-10-09
|
|
4
|
+
**Author:** alejandro gurovich
|
|
5
|
+
**Status:** Approved
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
This document describes three interconnected features for the `odsl` gem:
|
|
12
|
+
|
|
13
|
+
1. **Background adapters** — pluggable backends for `run_in_background` (Thread, ActiveJob)
|
|
14
|
+
2. **Audit system** — step-level execution tracking with idempotency and resume
|
|
15
|
+
3. **Distributed locking** — optional Redlock integration to prevent concurrent duplicate runs
|
|
16
|
+
|
|
17
|
+
The gem targets **Ruby on Rails applications**. All features are opt-in and backward-compatible: existing code with no `key:` or `lock` call behaves exactly as before.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 1. Background Adapters
|
|
22
|
+
|
|
23
|
+
### Architecture
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
lib/odsl/adapters/
|
|
27
|
+
base.rb ← common interface
|
|
28
|
+
thread.rb ← default (plain Ruby Thread)
|
|
29
|
+
active_job.rb ← Rails bridge (Sidekiq, Solid Queue, Good Job, etc.)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Interface (base.rb)
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
module Odsl
|
|
36
|
+
module Adapters
|
|
37
|
+
class Base
|
|
38
|
+
def self.enqueue(service_class:, method_name:, kwargs:, key: nil)
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Thread adapter
|
|
47
|
+
|
|
48
|
+
Current implementation. Returns a `Thread`; caller can block on `.value` to get the result. Used as the default when no adapter is configured.
|
|
49
|
+
|
|
50
|
+
### ActiveJob adapter
|
|
51
|
+
|
|
52
|
+
Defines `Odsl::OdslJob < ActiveJob::Base` (lives in `lib/odsl/adapters/odsl_job.rb`) that receives `service_class`, `method_name`, `kwargs`, and `key`. Compatible with any ActiveJob backend: Solid Queue, Sidekiq, Good Job, Delayed::Job, Resque.
|
|
53
|
+
|
|
54
|
+
Returns the enqueued job object (has `.job_id`).
|
|
55
|
+
|
|
56
|
+
### Configuration
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# config/initializers/odsl.rb
|
|
60
|
+
Odsl.configure do |config|
|
|
61
|
+
config.background_adapter = :active_job # default: :thread
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 2. Audit System
|
|
68
|
+
|
|
69
|
+
### Design decision: checkpoints at `.get()`
|
|
70
|
+
|
|
71
|
+
`.get(:variable)` is already the natural checkpoint of the DSL — it names and stores a step result. The audit system hooks into `.get()` to record a `StepRecord` after each call, making it the single source of truth for step identity.
|
|
72
|
+
|
|
73
|
+
### Database tables
|
|
74
|
+
|
|
75
|
+
**`odsl_execution_contexts`**
|
|
76
|
+
|
|
77
|
+
| Column | Type | Notes |
|
|
78
|
+
|--------|------|-------|
|
|
79
|
+
| `id` | bigint PK | |
|
|
80
|
+
| `idempotency_key` | string | unique index |
|
|
81
|
+
| `service_class` | string | e.g. `"Games::Service"` |
|
|
82
|
+
| `method_name` | string | e.g. `"create"` |
|
|
83
|
+
| `kwargs` | jsonb/text | initial kwargs, JSON serialized |
|
|
84
|
+
| `status` | string | `pending / running / completed / failed` |
|
|
85
|
+
| `expires_at` | datetime | indexed; nil = never expires |
|
|
86
|
+
| `created_at` | datetime | |
|
|
87
|
+
| `updated_at` | datetime | |
|
|
88
|
+
|
|
89
|
+
**`odsl_step_records`**
|
|
90
|
+
|
|
91
|
+
| Column | Type | Notes |
|
|
92
|
+
|--------|------|-------|
|
|
93
|
+
| `id` | bigint PK | |
|
|
94
|
+
| `execution_context_id` | bigint FK | indexed |
|
|
95
|
+
| `name` | string | variable name from `.get(:x)` |
|
|
96
|
+
| `step_index` | integer | execution order |
|
|
97
|
+
| `status` | string | `completed / failed` |
|
|
98
|
+
| `result` | jsonb/text | serialized result |
|
|
99
|
+
| `error_message` | text | populated on failure |
|
|
100
|
+
| `non_resumable` | boolean | true if result couldn't be serialized |
|
|
101
|
+
| `started_at` | datetime | |
|
|
102
|
+
| `completed_at` | datetime | |
|
|
103
|
+
|
|
104
|
+
### Public API
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# Run with audit
|
|
108
|
+
service.run(:create, key: 'user-123-game-chess')
|
|
109
|
+
|
|
110
|
+
# Resume from first failed/pending step
|
|
111
|
+
Games::Service.resume(key: 'user-123-game-chess')
|
|
112
|
+
|
|
113
|
+
# Background with audit + idempotency
|
|
114
|
+
service.run_in_background(:create, key: 'user-123-game-chess')
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Run flow (with key)
|
|
118
|
+
|
|
119
|
+
1. `run(:method, key:)` finds or creates an `ExecutionContext` for the key.
|
|
120
|
+
2. If the context is already `completed`, returns the stored result immediately (idempotency).
|
|
121
|
+
3. If `running`, raises `Odsl::AlreadyRunningError`.
|
|
122
|
+
4. Sets context status to `running`.
|
|
123
|
+
5. Executes the method. On each `.get(:x)` call, records a `StepRecord` with the serialized result.
|
|
124
|
+
6. On success: sets context status to `completed`.
|
|
125
|
+
7. On failure: sets context and the failing step to `failed` with the error message.
|
|
126
|
+
|
|
127
|
+
### Resume flow
|
|
128
|
+
|
|
129
|
+
1. Load `ExecutionContext` by key; raise `Odsl::ContextNotFoundError` if missing.
|
|
130
|
+
2. Collect names of already-`completed` steps.
|
|
131
|
+
3. Instantiate the service with the original `kwargs`.
|
|
132
|
+
4. Re-execute the method. When `.get(:x)` is called:
|
|
133
|
+
- If `x` is in the completed set → load result from `StepRecord`, skip re-execution of the preceding step.
|
|
134
|
+
- Otherwise → execute normally and record.
|
|
135
|
+
5. Continue until method finishes or a new failure occurs.
|
|
136
|
+
|
|
137
|
+
### Serialization contract
|
|
138
|
+
|
|
139
|
+
Results stored via `.get(:x)` must be JSON-serializable for resume to work (hashes, arrays, strings, numbers, structs with `to_h`). If a result cannot be serialized, the step is marked `completed` in the audit but flagged as `non_resumable: true`. On resume, non-resumable steps are re-executed (safe because idempotency lives in the business logic).
|
|
140
|
+
|
|
141
|
+
### TTL and cleanup
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
Odsl.configure do |config|
|
|
145
|
+
config.audit_ttl = 30.days # nil = keep forever
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Purge expired contexts (cascade deletes step_records)
|
|
149
|
+
Odsl.cleanup_expired!
|
|
150
|
+
|
|
151
|
+
# Via rake
|
|
152
|
+
rake odsl:cleanup
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`expires_at` is set on context creation from `config.audit_ttl`. Cleanup can be scheduled via Solid Queue scheduler, Sidekiq-Cron, Good Job cron, or a plain cron job — the gem only provides the method.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 3. Distributed Locking (Redlock)
|
|
160
|
+
|
|
161
|
+
### Motivation
|
|
162
|
+
|
|
163
|
+
Idempotency handles *"already completed — skip"*. Redlock handles *"currently running in another process — block"*. Without it, two simultaneous requests with the same key can both pass the idempotency check before either has written to the DB.
|
|
164
|
+
|
|
165
|
+
### Optional dependency
|
|
166
|
+
|
|
167
|
+
Redlock requires Redis. If `config.lock_redis_url` is nil (default), calling `.lock()` in a service raises `Odsl::LockingNotConfiguredError` at boot time.
|
|
168
|
+
|
|
169
|
+
### DSL method
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
def create
|
|
173
|
+
lock(:player, :game) # compose lock key from variable values
|
|
174
|
+
|
|
175
|
+
build(Game, Player)
|
|
176
|
+
.get(:game_object)
|
|
177
|
+
# ...
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The lock key is composed as:
|
|
182
|
+
```
|
|
183
|
+
odsl:{ServiceClass}:{method}:{var1_value}:{var2_value}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Example: `odsl:Games::Service:create:alejandro:chess`
|
|
187
|
+
|
|
188
|
+
### Behavior
|
|
189
|
+
|
|
190
|
+
- Lock is acquired at the start of `run` if `.lock()` is called in the method.
|
|
191
|
+
- If the lock cannot be acquired → raises `Odsl::LockError` immediately (fail fast).
|
|
192
|
+
- Lock is released when `run` finishes (success or error).
|
|
193
|
+
- A TTL fallback ensures the lock is released even if the process dies.
|
|
194
|
+
|
|
195
|
+
### Configuration
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
Odsl.configure do |config|
|
|
199
|
+
config.lock_redis_url = 'redis://localhost:6379' # nil = disabled
|
|
200
|
+
config.lock_ttl = 10_000 # milliseconds, fallback TTL
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Error handling
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
rescue Odsl::LockError
|
|
208
|
+
render json: { error: 'This action is already in progress.' }, status: :conflict
|
|
209
|
+
|
|
210
|
+
rescue Odsl::AlreadyRunningError
|
|
211
|
+
# Raised when run() is called with a key that is currently status: running
|
|
212
|
+
|
|
213
|
+
rescue Odsl::ContextNotFoundError
|
|
214
|
+
# Raised when resume() is called with a key that doesn't exist in the DB
|
|
215
|
+
|
|
216
|
+
rescue Odsl::LockingNotConfiguredError
|
|
217
|
+
# Raised at boot if .lock() is used but no Redis URL is configured
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## 4. Generator
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
rails generate odsl:install
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Generates:
|
|
229
|
+
- `db/migrate/TIMESTAMP_create_odsl_tables.rb` — creates both tables with correct indexes
|
|
230
|
+
- `config/initializers/odsl.rb` — configuration template with all options commented
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 5. New files summary
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
lib/odsl/
|
|
238
|
+
adapters/
|
|
239
|
+
base.rb
|
|
240
|
+
thread.rb
|
|
241
|
+
active_job.rb
|
|
242
|
+
odsl_job.rb ← ActiveJob::Base subclass used by active_job adapter
|
|
243
|
+
audit/
|
|
244
|
+
execution_context.rb ← ActiveRecord model
|
|
245
|
+
step_record.rb ← ActiveRecord model
|
|
246
|
+
serializer.rb ← JSON serialize/deserialize
|
|
247
|
+
locking/
|
|
248
|
+
redlock_adapter.rb ← wraps redlock-rb gem
|
|
249
|
+
null_adapter.rb ← no-op when locking not configured
|
|
250
|
+
configuration.rb ← Odsl::Configuration + Odsl.configure
|
|
251
|
+
railtie.rb ← Rails integration (generator, rake tasks)
|
|
252
|
+
lib/generators/
|
|
253
|
+
odsl/
|
|
254
|
+
install_generator.rb
|
|
255
|
+
templates/
|
|
256
|
+
migration.rb.tt
|
|
257
|
+
initializer.rb.tt
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 6. Backward compatibility
|
|
263
|
+
|
|
264
|
+
- `run(:method)` with no `key:` — no audit, no locking, behaves exactly as today.
|
|
265
|
+
- `run_in_background(:method)` with no `key:` — uses Thread adapter unless `background_adapter` is configured.
|
|
266
|
+
- `.lock()` without Redis configured — raises `Odsl::LockingNotConfiguredError` at boot.
|
|
267
|
+
- No new required dependencies — `redlock` and `activerecord` are optional; the gem works standalone.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Odsl
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
include ActiveRecord::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates an Odsl initializer and migration for audit tables"
|
|
12
|
+
|
|
13
|
+
def create_initializer
|
|
14
|
+
template "odsl_initializer.rb.erb", "config/initializers/odsl.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_migration
|
|
18
|
+
migration_template "create_odsl_tables.rb.erb",
|
|
19
|
+
"db/migrate/create_odsl_tables.rb"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class CreateOdslTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :odsl_execution_contexts do |t|
|
|
4
|
+
t.string :idempotency_key, null: false
|
|
5
|
+
t.string :service_class, null: false
|
|
6
|
+
t.string :method_name, null: false
|
|
7
|
+
t.text :kwargs
|
|
8
|
+
t.string :status, null: false, default: "pending"
|
|
9
|
+
t.datetime :expires_at
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :odsl_execution_contexts, :idempotency_key, unique: true
|
|
14
|
+
add_index :odsl_execution_contexts, :expires_at
|
|
15
|
+
|
|
16
|
+
create_table :odsl_step_records do |t|
|
|
17
|
+
t.references :execution_context, null: false,
|
|
18
|
+
foreign_key: { to_table: :odsl_execution_contexts }
|
|
19
|
+
t.string :name, null: false
|
|
20
|
+
t.integer :step_index, null: false
|
|
21
|
+
t.string :status, null: false, default: "pending"
|
|
22
|
+
t.text :result
|
|
23
|
+
t.text :error_message
|
|
24
|
+
t.boolean :non_resumable, default: false
|
|
25
|
+
t.datetime :started_at
|
|
26
|
+
t.datetime :completed_at
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Odsl.configure do |config|
|
|
2
|
+
# Background adapter: :thread (default) or :active_job
|
|
3
|
+
# config.background_adapter = :active_job
|
|
4
|
+
|
|
5
|
+
# How long to keep audit records (in seconds). nil = keep forever.
|
|
6
|
+
# config.audit_ttl = 7 * 24 * 3600 # 1 week
|
|
7
|
+
|
|
8
|
+
# Redis URL for distributed locking via Redlock. nil = no locking.
|
|
9
|
+
# config.lock_redis_url = ENV["REDIS_URL"]
|
|
10
|
+
|
|
11
|
+
# Lock TTL in milliseconds (default: 10_000 = 10 seconds)
|
|
12
|
+
# config.lock_ttl = 10_000
|
|
13
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# lib/odsl/adapters/active_job.rb
|
|
2
|
+
module Odsl
|
|
3
|
+
module Adapters
|
|
4
|
+
class ActiveJob < Base
|
|
5
|
+
def self.enqueue(service_class:, method_name:, kwargs:, key: nil, callable: nil)
|
|
6
|
+
unless defined?(::ActiveJob)
|
|
7
|
+
raise Odsl::Error,
|
|
8
|
+
"ActiveJob is not available. Add `gem 'activejob'` to your Gemfile."
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
require_relative "odsl_job"
|
|
12
|
+
Odsl::OdslJob.perform_later(
|
|
13
|
+
service_class: service_class,
|
|
14
|
+
method_name: method_name,
|
|
15
|
+
kwargs: kwargs,
|
|
16
|
+
key: key
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# lib/odsl/adapters/base.rb
|
|
2
|
+
module Odsl
|
|
3
|
+
module Adapters
|
|
4
|
+
class Base
|
|
5
|
+
def self.enqueue(service_class:, method_name:, kwargs:, key: nil, callable: nil)
|
|
6
|
+
raise NotImplementedError,
|
|
7
|
+
"#{self} must implement .enqueue(service_class:, method_name:, kwargs:, key:, callable:)"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# lib/odsl/adapters/odsl_job.rb
|
|
2
|
+
# Only loaded when ActiveJob is available
|
|
3
|
+
if defined?(ActiveJob)
|
|
4
|
+
module Odsl
|
|
5
|
+
class OdslJob < ActiveJob::Base
|
|
6
|
+
def perform(service_class:, method_name:, kwargs:, key: nil)
|
|
7
|
+
klass = Object.const_get(service_class)
|
|
8
|
+
klass.new(**kwargs.transform_keys(&:to_sym)).run(method_name.to_sym, key: key)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# lib/odsl/adapters/thread.rb
|
|
2
|
+
module Odsl
|
|
3
|
+
module Adapters
|
|
4
|
+
class Thread < Base
|
|
5
|
+
def self.enqueue(service_class:, method_name:, kwargs:, key: nil, callable: nil)
|
|
6
|
+
::Thread.new do
|
|
7
|
+
if callable
|
|
8
|
+
callable.call
|
|
9
|
+
else
|
|
10
|
+
klass = Object.const_get(service_class)
|
|
11
|
+
klass.new(**kwargs.transform_keys(&:to_sym)).run(method_name.to_sym, key: key)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# lib/odsl/audit/execution_context.rb
|
|
2
|
+
require "active_record"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Odsl
|
|
6
|
+
module Audit
|
|
7
|
+
class ExecutionContext < ActiveRecord::Base
|
|
8
|
+
self.table_name = "odsl_execution_contexts"
|
|
9
|
+
|
|
10
|
+
has_many :step_records,
|
|
11
|
+
class_name: "Odsl::Audit::StepRecord",
|
|
12
|
+
foreign_key: :execution_context_id,
|
|
13
|
+
dependent: :destroy
|
|
14
|
+
|
|
15
|
+
STATUSES = %w[pending running completed failed].freeze
|
|
16
|
+
|
|
17
|
+
validates :idempotency_key, presence: true, uniqueness: true
|
|
18
|
+
validates :service_class, presence: true
|
|
19
|
+
validates :method_name, presence: true
|
|
20
|
+
validates :status, inclusion: { in: STATUSES }
|
|
21
|
+
|
|
22
|
+
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at < ?", Time.now) }
|
|
23
|
+
|
|
24
|
+
def kwargs_hash
|
|
25
|
+
return {} if kwargs.nil?
|
|
26
|
+
JSON.parse(kwargs).transform_keys(&:to_sym)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def completed? = status == "completed"
|
|
30
|
+
def running? = status == "running"
|
|
31
|
+
def failed? = status == "failed"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# lib/odsl/audit/serializer.rb
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Odsl
|
|
5
|
+
module Audit
|
|
6
|
+
module Serializer
|
|
7
|
+
def self.dump(value)
|
|
8
|
+
JSON.generate(serialize(value))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.load(json_string)
|
|
12
|
+
return nil if json_string.nil?
|
|
13
|
+
JSON.parse(json_string)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.serializable?(value)
|
|
17
|
+
dump(value)
|
|
18
|
+
true
|
|
19
|
+
rescue JSON::GeneratorError, TypeError
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.serialize(value)
|
|
24
|
+
case value
|
|
25
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |v| serialize(v) }
|
|
26
|
+
when Array then value.map { |v| serialize(v) }
|
|
27
|
+
when Struct then serialize(value.to_h)
|
|
28
|
+
when String, Numeric, TrueClass, FalseClass, NilClass then value
|
|
29
|
+
else raise TypeError, "Cannot serialize #{value.class}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
private_class_method :serialize
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# lib/odsl/audit/step_record.rb
|
|
2
|
+
require "active_record"
|
|
3
|
+
|
|
4
|
+
module Odsl
|
|
5
|
+
module Audit
|
|
6
|
+
class StepRecord < ActiveRecord::Base
|
|
7
|
+
self.table_name = "odsl_step_records"
|
|
8
|
+
|
|
9
|
+
belongs_to :execution_context,
|
|
10
|
+
class_name: "Odsl::Audit::ExecutionContext",
|
|
11
|
+
foreign_key: :execution_context_id
|
|
12
|
+
|
|
13
|
+
STATUSES = %w[pending completed failed].freeze
|
|
14
|
+
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
validates :step_index, presence: true
|
|
17
|
+
validates :status, inclusion: { in: STATUSES }
|
|
18
|
+
|
|
19
|
+
def completed? = status == "completed"
|
|
20
|
+
def failed? = status == "failed"
|
|
21
|
+
|
|
22
|
+
def deserialized_result
|
|
23
|
+
Audit::Serializer.load(result)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Odsl
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :background_adapter, :audit_ttl,
|
|
4
|
+
:lock_redis_url, :lock_ttl
|
|
5
|
+
|
|
6
|
+
def initialize
|
|
7
|
+
@background_adapter = :thread
|
|
8
|
+
@audit_ttl = nil
|
|
9
|
+
@lock_redis_url = nil
|
|
10
|
+
@lock_ttl = 10_000
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
CONFIG_MUTEX = Mutex.new
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def configure
|
|
18
|
+
yield(config) if block_given?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def config
|
|
22
|
+
CONFIG_MUTEX.synchronize { @config ||= Configuration.new }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset_config!
|
|
26
|
+
CONFIG_MUTEX.synchronize { @config = Configuration.new }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def cleanup_expired!
|
|
30
|
+
return 0 unless defined?(Odsl::Audit::ExecutionContext)
|
|
31
|
+
count = Odsl::Audit::ExecutionContext.expired.count
|
|
32
|
+
Odsl::Audit::ExecutionContext.expired.destroy_all
|
|
33
|
+
count
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/odsl/dsl.rb
CHANGED
|
@@ -10,18 +10,17 @@ module Odsl
|
|
|
10
10
|
extend Configurable
|
|
11
11
|
|
|
12
12
|
def initialize(**kwargs)
|
|
13
|
-
|
|
14
13
|
@delegated = self
|
|
15
14
|
@kwargs = kwargs
|
|
16
15
|
@context = {}
|
|
17
16
|
@last_results = []
|
|
18
17
|
@with_attributes = nil
|
|
19
18
|
@finish_with = nil
|
|
20
|
-
|
|
19
|
+
|
|
21
20
|
kwargs.each do |key, value|
|
|
22
21
|
instance_variable_set("@#{key}", value)
|
|
23
22
|
@context[key] = value
|
|
24
|
-
end
|
|
23
|
+
end
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
class << self
|
|
@@ -30,41 +29,92 @@ module Odsl
|
|
|
30
29
|
def inmutable(bool)
|
|
31
30
|
@inmutable_mode = bool
|
|
32
31
|
end
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
|
|
33
|
+
def subscribe(message, to:)
|
|
34
|
+
subscriptions[message] = to
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def subscriptions
|
|
38
|
+
@subscriptions ||= {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dispatch(message, **kwargs)
|
|
42
|
+
method_name = subscriptions[message]
|
|
43
|
+
raise Error, "No subscription for '#{message}'" unless method_name
|
|
44
|
+
|
|
45
|
+
new(**kwargs).run(method_name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resume(key:)
|
|
49
|
+
ctx = Odsl::Audit::ExecutionContext.find_by(idempotency_key: key)
|
|
50
|
+
raise Odsl::ContextNotFoundError, "No execution context found for key: #{key}" unless ctx
|
|
51
|
+
raise Odsl::AlreadyRunningError, "Job with key '#{key}' is already running" if ctx.running?
|
|
52
|
+
return nil if ctx.completed?
|
|
53
|
+
|
|
54
|
+
kwargs = ctx.kwargs_hash
|
|
55
|
+
instance = new(**kwargs)
|
|
56
|
+
|
|
57
|
+
completed_step_names = ctx.step_records
|
|
58
|
+
.where(status: "completed", non_resumable: false)
|
|
59
|
+
.pluck(:name)
|
|
60
|
+
|
|
61
|
+
instance.instance_variable_set(:@_execution_context, ctx)
|
|
62
|
+
instance.instance_variable_set(:@_resuming_steps, completed_step_names)
|
|
63
|
+
instance.send(:_resume_method, ctx.method_name.to_sym, key: key)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
35
67
|
protected
|
|
36
68
|
|
|
37
69
|
def instantiate(object, public: false)
|
|
38
|
-
|
|
39
70
|
resolved_attrs = with_attributes_resolver
|
|
40
71
|
|
|
41
|
-
@last_results = [
|
|
42
|
-
|
|
72
|
+
@last_results = [] << if resolved_attrs.any?
|
|
43
73
|
resolved_attrs.is_a?(Array) ? object.new(*resolved_attrs) : object.new(**resolved_attrs)
|
|
44
74
|
else
|
|
45
75
|
object.new
|
|
46
|
-
end
|
|
76
|
+
end
|
|
47
77
|
|
|
48
78
|
self
|
|
49
79
|
end
|
|
50
80
|
|
|
51
81
|
def build(*modules)
|
|
52
|
-
@last_results = [
|
|
82
|
+
@last_results = [Odsl::Builder.build(*modules).new]
|
|
53
83
|
self
|
|
54
84
|
end
|
|
55
|
-
|
|
85
|
+
|
|
56
86
|
def with(*arguments)
|
|
57
87
|
@with_attributes = arguments
|
|
58
88
|
self
|
|
59
89
|
end
|
|
60
|
-
|
|
90
|
+
|
|
61
91
|
def value_return(*with)
|
|
62
92
|
@finish_with = with
|
|
63
93
|
end
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
|
|
95
|
+
def lock(*vars)
|
|
96
|
+
redis_url = Odsl.config.lock_redis_url
|
|
97
|
+
return unless redis_url
|
|
98
|
+
|
|
99
|
+
require "redlock"
|
|
100
|
+
require_relative "locking/lock_handle"
|
|
101
|
+
|
|
102
|
+
key_parts = [self.class.name] + vars.map { |v| instance_variable_get("@#{v}").to_s }
|
|
103
|
+
lock_key = key_parts.join(":")
|
|
104
|
+
ttl = Odsl.config.lock_ttl
|
|
105
|
+
|
|
106
|
+
client = Redlock::Client.new([redis_url])
|
|
107
|
+
lock_info = client.lock(lock_key, ttl)
|
|
108
|
+
raise Odsl::LockError, "Could not acquire lock for key: #{lock_key}" unless lock_info
|
|
109
|
+
|
|
110
|
+
@_current_lock = Odsl::Locking::LockHandle.new(client, lock_info)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
alias make call
|
|
114
|
+
alias link call
|
|
115
|
+
alias set call
|
|
116
|
+
alias get to
|
|
117
|
+
alias execute run
|
|
118
|
+
alias finish_with value_return
|
|
69
119
|
end
|
|
70
120
|
end
|
data/lib/odsl/errors.rb
ADDED