opera 0.5.0 → 0.6.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,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Performance benchmark for Opera operations.
4
+ #
5
+ # Exercises the full execution path: step dispatch, instruction iteration,
6
+ # context accessors, validate, transaction, success, finish_if, operation,
7
+ # operations, within, and always -- with nested inner operations and loops to
8
+ # simulate realistic workloads.
9
+ #
10
+ # Usage:
11
+ # ruby benchmarks/operation_benchmark.rb
12
+
13
+ require 'bundler/setup'
14
+ require 'benchmark'
15
+ require 'opera'
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Fake transaction class (no DB, just yields)
19
+ # ---------------------------------------------------------------------------
20
+ FakeTransaction = Class.new do
21
+ def self.transaction
22
+ yield
23
+ end
24
+ end
25
+
26
+ Opera::Operation::Config.configure do |config|
27
+ config.transaction_class = FakeTransaction
28
+ config.mode = :production # skip execution traces, like real production
29
+ end
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Leaf operation — called many times from within loops
33
+ # ---------------------------------------------------------------------------
34
+ LeafOperation = Class.new(Opera::Operation::Base) do
35
+ step :compute
36
+ step :output
37
+
38
+ def compute
39
+ context[:value] = params.fetch(:n, 1) * 2
40
+ end
41
+
42
+ def output
43
+ result.output = { value: context[:value] }
44
+ end
45
+ end
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Inner operation — calls LeafOperation in a loop
49
+ # ---------------------------------------------------------------------------
50
+ InnerOperation = Class.new(Opera::Operation::Base) do
51
+ context do
52
+ attr_accessor :results
53
+ end
54
+
55
+ step :process_batch
56
+ step :output
57
+
58
+ def process_batch
59
+ self.results = (1..params.fetch(:batch_size, 5)).map do |n|
60
+ LeafOperation.call(params: { n: n })
61
+ end
62
+ end
63
+
64
+ def output
65
+ result.output = { batch: results.map(&:output) }
66
+ end
67
+ end
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Validation-heavy operation
71
+ # ---------------------------------------------------------------------------
72
+ ValidationOperation = Class.new(Opera::Operation::Base) do
73
+ validate :schema
74
+
75
+ step :transform
76
+ step :output
77
+
78
+ def schema
79
+ # Return a successful Opera::Operation::Result (simulates dry-validation)
80
+ Opera::Operation::Result.new(output: params)
81
+ end
82
+
83
+ def transform
84
+ context[:transformed] = params.transform_values { |v| v.to_s.upcase }
85
+ end
86
+
87
+ def output
88
+ result.output = context[:transformed]
89
+ end
90
+ end
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Complex operation — combines everything
94
+ # ---------------------------------------------------------------------------
95
+ ComplexOperation = Class.new(Opera::Operation::Base) do
96
+ configure do |config|
97
+ config.transaction_class = FakeTransaction
98
+ end
99
+
100
+ context do
101
+ attr_accessor :profile, :batch_results, :validated
102
+ end
103
+
104
+ validate :schema
105
+
106
+ step :prepare
107
+ finish_if :skip_processing?
108
+
109
+ transaction do
110
+ step :create_record
111
+ step :update_record
112
+ end
113
+
114
+ operation :run_inner
115
+
116
+ within :with_timing do
117
+ step :heavy_computation
118
+ end
119
+
120
+ success do
121
+ step :notify
122
+ step :log_audit
123
+ end
124
+
125
+ step :processing_error
126
+
127
+ always :output
128
+
129
+ def schema
130
+ Opera::Operation::Result.new(output: params)
131
+ end
132
+
133
+ def prepare
134
+ self.validated = context[:schema_output]
135
+ context[:counter] = 0
136
+ end
137
+
138
+ def skip_processing?
139
+ params[:skip] == true
140
+ end
141
+
142
+ def create_record
143
+ self.profile = { id: rand(1000), name: validated[:name] }
144
+ end
145
+
146
+ def update_record
147
+ profile[:updated_at] = Time.now.to_i
148
+ end
149
+
150
+ def run_inner
151
+ InnerOperation.call(params: { batch_size: params.fetch(:batch_size, 5) })
152
+ end
153
+
154
+ def heavy_computation
155
+ # Simulate CPU work: string operations in a loop
156
+ 50.times do |i|
157
+ context[:counter] += i
158
+ "operation-#{i}-#{context[:counter]}".hash
159
+ end
160
+ end
161
+
162
+ def notify
163
+ context[:notified] = true
164
+ end
165
+
166
+ def log_audit
167
+ context[:audited] = true
168
+ end
169
+
170
+ def processing_error
171
+ result.add_error(:base, 'processing failed')
172
+ end
173
+
174
+ def output
175
+ result.output = {
176
+ profile: profile,
177
+ counter: context[:counter],
178
+ batch: context[:run_inner_output]
179
+ }
180
+ end
181
+
182
+ def with_timing
183
+ yield
184
+ end
185
+ end
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Within operation — wraps steps and inner operations with a custom method
189
+ # ---------------------------------------------------------------------------
190
+ WithinOperation = Class.new(Opera::Operation::Base) do
191
+ configure do |config|
192
+ config.transaction_class = FakeTransaction
193
+ end
194
+
195
+ context do
196
+ attr_accessor :log, default: -> { [] }
197
+ end
198
+
199
+ step :prepare
200
+
201
+ within :with_connection do
202
+ step :query_one
203
+ step :query_two
204
+ operation :fetch_leaf
205
+ end
206
+
207
+ transaction do
208
+ within :with_lock do
209
+ step :write_one
210
+ step :write_two
211
+ end
212
+ step :write_three
213
+ end
214
+
215
+ step :output
216
+
217
+ def prepare
218
+ context[:counter] = 0
219
+ end
220
+
221
+ def query_one
222
+ context[:counter] += 1
223
+ log << :query_one
224
+ end
225
+
226
+ def query_two
227
+ context[:counter] += 1
228
+ log << :query_two
229
+ end
230
+
231
+ def fetch_leaf
232
+ LeafOperation.call(params: { n: context[:counter] })
233
+ end
234
+
235
+ def write_one
236
+ context[:counter] += 10
237
+ log << :write_one
238
+ end
239
+
240
+ def write_two
241
+ context[:counter] += 10
242
+ log << :write_two
243
+ end
244
+
245
+ def write_three
246
+ context[:counter] += 1
247
+ log << :write_three
248
+ end
249
+
250
+ def output
251
+ result.output = {
252
+ counter: context[:counter],
253
+ log: log,
254
+ leaf: context[:fetch_leaf_output]
255
+ }
256
+ end
257
+
258
+ def with_connection
259
+ log << :connect
260
+ yield
261
+ log << :disconnect
262
+ end
263
+
264
+ def with_lock
265
+ log << :lock
266
+ yield
267
+ log << :unlock
268
+ end
269
+ end
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Operations (plural) consumer — calls multiple inner operations
273
+ # ---------------------------------------------------------------------------
274
+ BatchOperation = Class.new(Opera::Operation::Base) do
275
+ operations :run_all
276
+ step :output
277
+
278
+ def run_all
279
+ (1..params.fetch(:count, 3)).map do |n|
280
+ LeafOperation.call(params: { n: n })
281
+ end
282
+ end
283
+
284
+ def output
285
+ result.output = context[:run_all_output]
286
+ end
287
+ end
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Always operation — exercises always steps on both success and failure paths
291
+ # ---------------------------------------------------------------------------
292
+ AlwaysOperation = Class.new(Opera::Operation::Base) do
293
+ context do
294
+ attr_accessor :log, default: -> { [] }
295
+ end
296
+
297
+ step :prepare
298
+ step :process
299
+ always :audit
300
+ always :cleanup
301
+
302
+ def prepare
303
+ log << :prepare
304
+ context[:value] = params.fetch(:value, 0)
305
+ end
306
+
307
+ def process
308
+ if params[:fail]
309
+ result.add_error(:base, 'processing failed')
310
+ else
311
+ context[:value] *= 2
312
+ log << :process
313
+ end
314
+ end
315
+
316
+ def audit
317
+ log << (result.success? ? :audit_success : :audit_failure)
318
+ result.output = { value: context[:value], log: log, success: result.success?, failure: result.failure? }
319
+ end
320
+
321
+ def cleanup
322
+ log << :cleanup
323
+ end
324
+ end
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Benchmark
328
+ # ---------------------------------------------------------------------------
329
+ ITERATIONS = 1000
330
+ PARAMS = { name: 'benchmark', batch_size: 5 }.freeze
331
+ BATCH_PARAMS = { count: 5 }.freeze
332
+ VALIDATION_PARAMS = { first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com' }.freeze
333
+ WITHIN_PARAMS = {}.freeze
334
+ ALWAYS_SUCCESS_PARAMS = { value: 21 }.freeze
335
+ ALWAYS_FAILURE_PARAMS = { value: 21, fail: true }.freeze
336
+
337
+ # Warm up
338
+ 3.times do
339
+ ComplexOperation.call(params: PARAMS)
340
+ BatchOperation.call(params: BATCH_PARAMS)
341
+ ValidationOperation.call(params: VALIDATION_PARAMS)
342
+ WithinOperation.call(params: WITHIN_PARAMS)
343
+ AlwaysOperation.call(params: ALWAYS_SUCCESS_PARAMS)
344
+ AlwaysOperation.call(params: ALWAYS_FAILURE_PARAMS)
345
+ end
346
+
347
+ puts "Opera v#{Opera::VERSION} — #{ITERATIONS} iterations each"
348
+ puts "Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
349
+ puts '-' * 60
350
+
351
+ Benchmark.bm(35) do |x|
352
+ x.report('ComplexOperation (nested + tx):') do
353
+ ITERATIONS.times { ComplexOperation.call(params: PARAMS) }
354
+ end
355
+
356
+ x.report('BatchOperation (operations):') do
357
+ ITERATIONS.times { BatchOperation.call(params: BATCH_PARAMS) }
358
+ end
359
+
360
+ x.report('ValidationOperation (validate):') do
361
+ ITERATIONS.times { ValidationOperation.call(params: VALIDATION_PARAMS) }
362
+ end
363
+
364
+ x.report('WithinOperation (within + tx):') do
365
+ ITERATIONS.times { WithinOperation.call(params: WITHIN_PARAMS) }
366
+ end
367
+
368
+ x.report('LeafOperation (minimal):') do
369
+ ITERATIONS.times { LeafOperation.call(params: { n: 42 }) }
370
+ end
371
+
372
+ x.report('AlwaysOperation (success path):') do
373
+ ITERATIONS.times { AlwaysOperation.call(params: ALWAYS_SUCCESS_PARAMS) }
374
+ end
375
+
376
+ x.report('AlwaysOperation (failure path):') do
377
+ ITERATIONS.times { AlwaysOperation.call(params: ALWAYS_FAILURE_PARAMS) }
378
+ end
379
+
380
+ # Total operations executed in ComplexOperation run:
381
+ # 1 complex + 1 inner + 5 leaf = 7 operations per iteration
382
+ # = 7000 total operation instantiations for ComplexOperation alone
383
+ total_ops = ITERATIONS * 7
384
+ puts "\nComplexOperation spawns ~#{total_ops} total operation instances across #{ITERATIONS} calls"
385
+ end
@@ -0,0 +1,267 @@
1
+ # Always
2
+
3
+ `always` runs a step unconditionally at the end of the operation pipeline, after all regular steps have run (or been skipped). Unlike a regular `step`, it is never skipped — not when a prior step adds an error, not when `finish!` or `finish_if` DSL is called.
4
+
5
+ ## Placement rules
6
+
7
+ - `always` steps must appear **after all other instructions** at the top level of the operation.
8
+ - Once an `always` is declared, only further `always` steps may follow — any other instruction (`step`, `operation`, `transaction`, `within`, etc.) raises an `ArgumentError` at class load time.
9
+ - `always` **cannot** be used inside blocks (`transaction do`, `within do`, `success do`, `validate do`). Doing so raises an `ArgumentError` at class load time.
10
+
11
+ ```ruby
12
+ # correct
13
+ step :a
14
+ step :b
15
+ always :c
16
+ always :d
17
+
18
+ # raises ArgumentError — step follows always
19
+ step :a
20
+ always :b
21
+ step :c
22
+
23
+ # raises ArgumentError — always inside a transaction block
24
+ transaction do
25
+ step :a
26
+ always :b # not allowed here
27
+ end
28
+ ```
29
+
30
+ ## Basic usage
31
+
32
+ ```ruby
33
+ class Order::Submit < Opera::Operation::Base
34
+ context do
35
+ attr_accessor :order
36
+ end
37
+
38
+ dependencies do
39
+ attr_reader :current_account, :audit_logger
40
+ end
41
+
42
+ step :build
43
+ step :charge
44
+ step :send_confirmation
45
+ always :audit_log
46
+
47
+ def build
48
+ self.order = current_account.orders.build(params)
49
+ end
50
+
51
+ def charge
52
+ result.add_error(:base, 'card declined') unless order.charge!
53
+ end
54
+
55
+ def send_confirmation
56
+ # only reached when charge succeeds
57
+ OrderMailer.confirmation(order).deliver_later
58
+ end
59
+
60
+ def audit_log
61
+ # always runs, regardless of whether charge succeeded or failed
62
+ audit_logger.record(order: order, success: result.success?)
63
+ end
64
+ end
65
+ ```
66
+
67
+ ## Inspecting result state inside an always step
68
+
69
+ `result.success?` and `result.failure?` reflect the state of the operation **at the point `always` runs** — i.e. after all regular steps have executed (or been skipped due to failure). This lets you branch on the final outcome:
70
+
71
+ ```ruby
72
+ class Profile::Delete < Opera::Operation::Base
73
+ context do
74
+ attr_accessor :profile
75
+ end
76
+
77
+ dependencies do
78
+ attr_reader :current_account, :notifier
79
+ end
80
+
81
+ step :find
82
+ step :destroy
83
+ always :notify
84
+
85
+ def find
86
+ self.profile = current_account.profiles.find(params[:id])
87
+ end
88
+
89
+ def destroy
90
+ result.add_error(:base, 'cannot delete') unless profile.destroy
91
+ end
92
+
93
+ def notify
94
+ if result.success?
95
+ notifier.call(event: :deleted, profile_id: params[:id])
96
+ else
97
+ notifier.call(event: :delete_failed, profile_id: params[:id], errors: result.errors)
98
+ end
99
+ end
100
+ end
101
+ ```
102
+
103
+ ## Multiple always steps
104
+
105
+ Multiple `always` steps are allowed and run in the order they are declared in:
106
+
107
+ ```ruby
108
+ class Report::Generate < Opera::Operation::Base
109
+ dependencies do
110
+ attr_reader :audit_logger, :metrics
111
+ end
112
+
113
+ step :fetch_data
114
+ step :render
115
+ always :record_audit
116
+ always :record_metrics
117
+
118
+ def fetch_data
119
+ # ...
120
+ end
121
+
122
+ def render
123
+ # ...
124
+ end
125
+
126
+ def record_audit
127
+ audit_logger.call(success: result.success?, errors: result.errors)
128
+ end
129
+
130
+ def record_metrics
131
+ metrics.increment(result.success? ? 'report.success' : 'report.failure')
132
+ end
133
+ end
134
+ ```
135
+
136
+ ## Operation finishes early
137
+
138
+ ### With finish_if
139
+
140
+ `finish_if` halts execution successfully when its method returns truthy — subsequent regular steps are skipped, but `always` steps still run. Inside the always step, `result.success?` returns `true` because no errors were added:
141
+
142
+ ```ruby
143
+ class Import::Run < Opera::Operation::Base
144
+ dependencies do
145
+ attr_reader :audit_logger
146
+ end
147
+
148
+ step :check_preconditions
149
+ finish_if :already_imported?
150
+ step :import
151
+ step :output
152
+ always :record_attempt
153
+
154
+ def check_preconditions
155
+ # validate source data is present
156
+ end
157
+
158
+ def already_imported?
159
+ Import.exists?(ref: params[:ref])
160
+ end
161
+
162
+ def import
163
+ Import.create!(params)
164
+ end
165
+
166
+ def output
167
+ result.output = { imported: true }
168
+ end
169
+
170
+ def record_attempt
171
+ # called whether import ran, was skipped via finish_if, or failed
172
+ audit_logger.call(ref: params[:ref], success: result.success?)
173
+ end
174
+ end
175
+ ```
176
+
177
+ ### With finish!
178
+
179
+ Calling `finish!` inside a step halts execution immediately and marks the operation successful. `always` steps still run afterwards:
180
+
181
+ ```ruby
182
+ class Profile::Upsert < Opera::Operation::Base
183
+ context do
184
+ attr_accessor :profile
185
+ end
186
+
187
+ dependencies do
188
+ attr_reader :current_account, :audit_logger
189
+ end
190
+
191
+ step :find_existing
192
+ step :update_existing
193
+ step :create_new
194
+ step :output
195
+ always :audit_log
196
+
197
+ def find_existing
198
+ self.profile = current_account.profiles.find_by(email: params[:email])
199
+ end
200
+
201
+ def update_existing
202
+ return unless profile
203
+
204
+ profile.update!(params)
205
+ finish!
206
+ end
207
+
208
+ def create_new
209
+ self.profile = current_account.profiles.create!(params)
210
+ end
211
+
212
+ def output
213
+ result.output = { model: profile }
214
+ end
215
+
216
+ def audit_log
217
+ # runs whether the record was updated (finish! path), created, or failed
218
+ audit_logger.record(profile: profile, success: result.success?)
219
+ end
220
+ end
221
+ ```
222
+
223
+ ## Combining with DSL blocks
224
+
225
+ `always` cannot be placed inside `transaction`, `within` or `validate` blocks. Place it after those blocks at the top level:
226
+
227
+ ```ruby
228
+ class Ledger::Transfer < Opera::Operation::Base
229
+ configure do |config|
230
+ config.transaction_class = ActiveRecord::Base
231
+ end
232
+
233
+ dependencies do
234
+ attr_reader :audit_logger
235
+ end
236
+
237
+ transaction do
238
+ step :debit
239
+ step :credit
240
+ end
241
+
242
+ step :output
243
+ always :record_attempt
244
+
245
+ def debit
246
+ result.add_error(:base, 'insufficient funds') unless account.debit(params[:amount])
247
+ end
248
+
249
+ def credit
250
+ account.credit(params[:amount])
251
+ end
252
+
253
+ def output
254
+ result.output = { transferred: params[:amount] }
255
+ end
256
+
257
+ def record_attempt
258
+ # runs after the transaction (and rollback, if any) has settled.
259
+ # result.success? / result.failure? reflect the final outcome.
260
+ audit_logger.call(
261
+ params: params,
262
+ success: result.success?,
263
+ errors: result.errors
264
+ )
265
+ end
266
+ end
267
+ ```
@@ -0,0 +1,79 @@
1
+ # Basic Operation
2
+
3
+ A simple operation that validates input, creates a record, sends an email, and returns output.
4
+
5
+ ```ruby
6
+ class Profile::Create < Opera::Operation::Base
7
+ context do
8
+ attr_accessor :profile
9
+ end
10
+
11
+ dependencies do
12
+ attr_reader :current_account, :mailer
13
+ end
14
+
15
+ validate :profile_schema
16
+
17
+ step :create
18
+ step :send_email
19
+ step :output
20
+
21
+ def profile_schema
22
+ Dry::Validation.Schema do
23
+ required(:first_name).filled
24
+ end.call(params)
25
+ end
26
+
27
+ def create
28
+ self.profile = current_account.profiles.create(params)
29
+ end
30
+
31
+ def send_email
32
+ mailer&.send_mail(profile: profile)
33
+ end
34
+
35
+ def output
36
+ result.output = { model: profile }
37
+ end
38
+ end
39
+ ```
40
+
41
+ ## Call with valid parameters
42
+
43
+ ```ruby
44
+ Profile::Create.call(params: {
45
+ first_name: :foo,
46
+ last_name: :bar
47
+ }, dependencies: {
48
+ mailer: MyMailer,
49
+ current_account: Account.find(1)
50
+ })
51
+
52
+ #<Opera::Operation::Result:0x0000561636dced60 @errors={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 30, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-14 16:04:08", updated_at: "2020-08-14 16:04:08", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
53
+ ```
54
+
55
+ ## Call with INVALID parameters - missing first_name
56
+
57
+ ```ruby
58
+ Profile::Create.call(params: {
59
+ last_name: :bar
60
+ }, dependencies: {
61
+ mailer: MyMailer,
62
+ current_account: Account.find(1)
63
+ })
64
+
65
+ #<Opera::Operation::Result:0x0000562d3f635390 @errors={:first_name=>["is missing"]}, @information={}, @executions=[:profile_schema]>
66
+ ```
67
+
68
+ ## Call with MISSING dependencies
69
+
70
+ ```ruby
71
+ Profile::Create.call(params: {
72
+ first_name: :foo,
73
+ last_name: :bar
74
+ }, dependencies: {
75
+ current_account: Account.find(1)
76
+ })
77
+
78
+ #<Opera::Operation::Result:0x007f87ba2c8f00 @errors={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 33, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2019-01-03 12:04:25", updated_at: "2019-01-03 12:04:25", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
79
+ ```