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,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
- end
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 = [ ] << if resolved_attrs.any?
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 = [ Odsl::Builder.build(*modules).new ]
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
- alias make call
66
- alias link call
67
- alias get to
68
- alias execute run
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
@@ -0,0 +1,8 @@
1
+ module Odsl
2
+ class Error < StandardError; end
3
+
4
+ class LockError < Error; end
5
+ class LockingNotConfiguredError < Error; end
6
+ class AlreadyRunningError < Error; end
7
+ class ContextNotFoundError < Error; end
8
+ end
@@ -0,0 +1,9 @@
1
+ module Odsl
2
+ module Locking
3
+ LockHandle = Struct.new(:client, :lock_info) do
4
+ def unlock
5
+ client.unlock(lock_info)
6
+ end
7
+ end
8
+ end
9
+ end