active_orchestrator 0.1.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,695 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module ActiveWorkflow
6
+ module Stores
7
+ # ActiveRecord-backed persistence implementation.
8
+ class ActiveRecord < Base
9
+ TERMINAL_STATES = %w[completed failed cancelled timed_out].freeze
10
+
11
+ module Models
12
+ class Execution < ::ActiveRecord::Base
13
+ self.table_name = "aw_executions"
14
+
15
+ has_many :steps,
16
+ class_name: "ActiveWorkflow::Stores::ActiveRecord::Models::Step",
17
+ foreign_key: :execution_id,
18
+ inverse_of: :execution,
19
+ dependent: :destroy
20
+
21
+ has_many :events,
22
+ class_name: "ActiveWorkflow::Stores::ActiveRecord::Models::Event",
23
+ foreign_key: :execution_id,
24
+ inverse_of: :execution,
25
+ dependent: :destroy
26
+ end
27
+
28
+ class Step < ::ActiveRecord::Base
29
+ self.table_name = "aw_steps"
30
+
31
+ belongs_to :execution,
32
+ class_name: "ActiveWorkflow::Stores::ActiveRecord::Models::Execution",
33
+ foreign_key: :execution_id,
34
+ inverse_of: :steps
35
+
36
+ scope :ordered, -> { order(:position) }
37
+
38
+ enum :state, {
39
+ pending: "pending",
40
+ running: "running",
41
+ waiting: "waiting",
42
+ completed: "completed",
43
+ failed: "failed",
44
+ timed_out: "timed_out",
45
+ cancelled: "cancelled",
46
+ compensating: "compensating",
47
+ compensated: "compensated"
48
+ }
49
+ end
50
+
51
+ class Event < ::ActiveRecord::Base
52
+ self.table_name = "aw_events"
53
+
54
+ belongs_to :execution,
55
+ class_name: "ActiveWorkflow::Stores::ActiveRecord::Models::Execution",
56
+ foreign_key: :execution_id,
57
+ inverse_of: :events
58
+ end
59
+ end
60
+
61
+ def start_execution(workflow_class:, context:, steps:, idempotency_key:, timeout:, metadata: {})
62
+ now = clock.call
63
+ record = nil
64
+
65
+ ::ActiveRecord::Base.transaction do
66
+ if idempotency_key
67
+ existing = Models::Execution.lock.find_by(idempotency_key:)
68
+ return build_execution(existing) if existing
69
+ end
70
+
71
+ record = Models::Execution.create!(
72
+ workflow_class:,
73
+ state: "running",
74
+ ctx: serializer.dump(context),
75
+ cursor_step: steps.first && steps.first[:name],
76
+ idempotency_key:,
77
+ metadata: (metadata || {}).stringify_keys,
78
+ timeout_at: timeout ? now + timeout : nil,
79
+ last_enqueued_at: now
80
+ )
81
+
82
+ steps.each_with_index do |step, index|
83
+ Models::Step.create!(
84
+ execution: record,
85
+ name: step[:name].to_s,
86
+ style: step[:style].to_s,
87
+ options: serializable_step_options(step[:options]),
88
+ position: step[:position] || index,
89
+ state: index.zero? ? "pending" : "pending",
90
+ attempts: 0
91
+ )
92
+ end
93
+
94
+ if steps.empty?
95
+ record.update!(state: "completed", cursor_step: nil, completed_at: now)
96
+ end
97
+ end
98
+
99
+ enqueue_runner(record.id) unless steps.empty?
100
+ build_execution(record)
101
+ end
102
+
103
+ def serializable_step_options(options)
104
+ return {} unless options.is_a?(Hash)
105
+
106
+ options.each_with_object({}) do |(key, value), hash|
107
+ hash[key.to_s] = case value
108
+ when Proc, Method
109
+ nil
110
+ when Hash
111
+ serializable_step_options(value)
112
+ when Array
113
+ value.map { |item| item.is_a?(Hash) ? serializable_step_options(item) : item }
114
+ when Symbol, String, Numeric, TrueClass, FalseClass, NilClass
115
+ value
116
+ else
117
+ value.respond_to?(:name) ? value.name : value.to_s
118
+ end
119
+ end.compact
120
+ end
121
+
122
+ def load_execution(id)
123
+ record = Models::Execution.find_by(id:)
124
+ build_execution(record) if record
125
+ end
126
+
127
+ def enqueue_runner(execution_id, run_at: nil)
128
+ if run_at
129
+ ActiveWorkflow::Jobs::RunnerJob.set(wait_until: run_at).perform_later(execution_id)
130
+ else
131
+ ActiveWorkflow::Jobs::RunnerJob.perform_later(execution_id)
132
+ end
133
+ end
134
+
135
+ def with_execution_lock(execution_id)
136
+ ::ActiveRecord::Base.transaction do
137
+ record = Models::Execution.lock.find_by(id: execution_id)
138
+ return unless record
139
+
140
+ yield(record)
141
+ end
142
+ end
143
+
144
+ def process_execution(execution_id)
145
+ with_execution_lock(execution_id) do |execution|
146
+ Processor.new(self, execution).process!
147
+ end
148
+ end
149
+
150
+ def complete_step!(execution_id, step_name, payload:, idempotency_key: nil)
151
+ with_execution_lock(execution_id) do |execution|
152
+ step = execution.steps.lock.find_by(name: step_name.to_s)
153
+ if step&.completed? && idempotency_key && step.completion_idempotency_key == idempotency_key
154
+ return build_execution(execution)
155
+ end
156
+
157
+ raise ActiveWorkflow::Errors::StepNotWaiting, "Step #{step_name} is not waiting" unless step&.waiting?
158
+
159
+ if idempotency_key && step.completion_idempotency_key.present? && step.completion_idempotency_key != idempotency_key
160
+ raise ActiveWorkflow::Errors::AsyncCompletionConflict, "Completion idempotency mismatch"
161
+ end
162
+
163
+ return build_execution(execution) if step.completion_idempotency_key.present? && step.completion_idempotency_key == idempotency_key
164
+
165
+ definition = step_definition(execution, step)
166
+ ctx = context_from(execution)
167
+
168
+ unless definition.options[:fire_and_forget]
169
+ store_keys = [definition.name.to_sym]
170
+ store_result = definition.options[:store_result_as]&.to_sym
171
+ store_keys << store_result if store_result && store_result != definition.name.to_sym
172
+
173
+ store_keys.each do |key|
174
+ ctx[key] = payload unless payload.nil?
175
+ end
176
+ end
177
+
178
+ step.update!(
179
+ state: "completed",
180
+ completion_payload: payload,
181
+ completion_idempotency_key: idempotency_key,
182
+ waiting_since: nil,
183
+ timeout_at: nil,
184
+ completed_at: clock.call
185
+ )
186
+
187
+ ActiveSupport::Notifications.instrument("active_workflow.step.completed_async",
188
+ execution_id: execution.id,
189
+ step: step.name,
190
+ workflow: execution.workflow_class)
191
+
192
+ persist_context(execution, ctx)
193
+ advance_cursor(execution, step)
194
+ enqueue_runner(execution.id)
195
+ end
196
+ end
197
+
198
+ def fail_step!(execution_id, step_name, error_class:, message:, details:, idempotency_key: nil)
199
+ with_execution_lock(execution_id) do |execution|
200
+ step = execution.steps.lock.find_by(name: step_name.to_s)
201
+ raise ActiveWorkflow::Errors::StepNotWaiting, "Step #{step_name} is not waiting" unless step&.waiting?
202
+
203
+ definition = step_definition(execution, step)
204
+ current_attempts = step.attempts.to_i
205
+ record_failure!(execution, step,
206
+ error_class:,
207
+ message:,
208
+ details: details || {},
209
+ attempts: current_attempts,
210
+ async: true)
211
+
212
+ ActiveSupport::Notifications.instrument("active_workflow.step.failed_async",
213
+ execution_id: execution.id,
214
+ step: step.name,
215
+ workflow: execution.workflow_class,
216
+ error_class: error_class,
217
+ message: message)
218
+
219
+ schedule_retry_or_fail!(execution, step, definition)
220
+ end
221
+ end
222
+
223
+ def extend_timeout!(execution_id, step_name, by:)
224
+ with_execution_lock(execution_id) do |execution|
225
+ step = execution.steps.lock.find_by(name: step_name.to_s)
226
+ return unless step&.waiting?
227
+
228
+ step.update!(timeout_at: step.timeout_at && step.timeout_at + by)
229
+ end
230
+ end
231
+
232
+ def heartbeat!(execution_id, step_name, at: clock.call)
233
+ with_execution_lock(execution_id) do |execution|
234
+ step = execution.steps.lock.find_by(name: step_name.to_s)
235
+ return unless step&.waiting?
236
+
237
+ step.update!(last_heartbeat_at: at)
238
+ end
239
+ end
240
+
241
+ def signal!(execution_id, name, payload: nil)
242
+ with_execution_lock(execution_id) do |execution|
243
+ Models::Event.create!(execution:, name: name.to_s, payload: payload)
244
+
245
+ ActiveSupport::Notifications.instrument("active_workflow.signal.received",
246
+ execution_id: execution.id,
247
+ signal: name)
248
+
249
+ apply_signal_handler(execution, name, payload)
250
+
251
+ if execution.steps.where(name: name.to_s, state: "waiting").exists?
252
+ enqueue_runner(execution.id)
253
+ end
254
+ end
255
+ end
256
+
257
+ def context_from(execution)
258
+ ActiveWorkflow::Context.new(serializer.load(execution.ctx))
259
+ end
260
+
261
+ def persist_context(execution, ctx)
262
+ execution.update!(ctx: serializer.dump(ctx.to_h))
263
+ end
264
+
265
+ def build_execution(record)
266
+ ActiveWorkflow::Execution.new(
267
+ id: record.id,
268
+ workflow_class: record.workflow_class,
269
+ state: record.state,
270
+ ctx: serializer.load(record.ctx),
271
+ cursor_step: record.cursor_step&.to_sym,
272
+ created_at: record.created_at,
273
+ updated_at: record.updated_at,
274
+ store: self
275
+ )
276
+ end
277
+
278
+ def step_definition(execution, step)
279
+ execution.workflow_class.constantize.step_definition(step.name)
280
+ end
281
+
282
+ def apply_signal_handler(execution, name, payload)
283
+ workflow_class = execution.workflow_class.constantize
284
+ handler = workflow_class.signal_handler_for(name)
285
+ return unless handler
286
+
287
+ ctx = context_from(execution)
288
+ workflow = workflow_class.new(context: ctx, execution_id: execution.id)
289
+
290
+ workflow.send(handler, payload)
291
+ persist_context(execution, workflow.ctx)
292
+ end
293
+
294
+ def advance_cursor(execution, step)
295
+ next_step = execution.steps.ordered.where("position > ?", step.position).first
296
+ if next_step
297
+ execution.update!(
298
+ cursor_step: next_step.name,
299
+ state: next_step.waiting? ? "waiting" : "running"
300
+ )
301
+ else
302
+ execution.update!(
303
+ cursor_step: nil,
304
+ state: "completed",
305
+ completed_at: clock.call
306
+ )
307
+ ActiveSupport::Notifications.instrument("active_workflow.execution.completed",
308
+ execution_id: execution.id,
309
+ workflow: execution.workflow_class)
310
+ end
311
+ end
312
+
313
+ def record_failure!(execution, step, error_class:, message:, details:, attempts:, async: false)
314
+ step.update!(
315
+ state: async ? "failed" : "failed",
316
+ last_error_class: error_class,
317
+ last_error_message: message,
318
+ last_error_details: details,
319
+ attempts: attempts,
320
+ last_error_at: clock.call
321
+ )
322
+ execution.update!(state: "failed", last_error_class: error_class, last_error_message: message)
323
+ end
324
+
325
+ def schedule_retry_or_fail!(execution, step, definition)
326
+ retry_config = (definition.options[:retry] || {}).deep_symbolize_keys
327
+
328
+ return transition_to_failed!(execution, step) if retry_config.empty?
329
+
330
+ current_attempts = step.attempts.to_i
331
+ max_attempts = retry_config[:max] || Float::INFINITY
332
+
333
+ if current_attempts < max_attempts
334
+ retry_index = [current_attempts, 1].max
335
+ delay = Backoff.calculate(retry_config, attempts: retry_index)
336
+ scheduled_at = clock.call + delay
337
+
338
+ step.update!(state: "pending", attempts: current_attempts, scheduled_at: scheduled_at)
339
+ execution.update!(state: "running", cursor_step: definition.name)
340
+
341
+ ActiveSupport::Notifications.instrument("active_workflow.retry.scheduled",
342
+ execution_id: execution.id,
343
+ workflow: execution.workflow_class,
344
+ step: step.name,
345
+ attempts: current_attempts + 1,
346
+ wait: delay)
347
+
348
+ enqueue_runner(execution.id, run_at: scheduled_at)
349
+ else
350
+ transition_to_failed!(execution, step)
351
+ end
352
+ end
353
+
354
+ def transition_to_failed!(execution, step)
355
+ step.update!(state: "failed")
356
+ run_compensations!(execution, upto: step.position)
357
+ execution.update!(state: "failed", cursor_step: nil)
358
+ ActiveSupport::Notifications.instrument("active_workflow.execution.failed",
359
+ execution_id: execution.id,
360
+ workflow: execution.workflow_class,
361
+ step: step.name)
362
+ end
363
+
364
+ private
365
+
366
+ def run_compensations!(execution, upto:)
367
+ workflow_class = execution.workflow_class.constantize
368
+ ctx = context_from(execution)
369
+ workflow = workflow_class.new(context: ctx, execution_id: execution.id)
370
+
371
+ steps = execution.steps.where("position <= ?", upto).order(position: :desc)
372
+ steps.each do |step|
373
+ definition = workflow_class.step_definition(step.name)
374
+ compensate = definition.options[:compensate]
375
+ next unless compensate
376
+
377
+ ActiveSupport::Notifications.instrument("active_workflow.step.compensating",
378
+ execution_id: execution.id,
379
+ step: definition.name) do
380
+ case compensate
381
+ when Symbol
382
+ workflow.send(compensate)
383
+ when Proc
384
+ workflow.instance_exec(workflow.ctx, &compensate)
385
+ else
386
+ workflow.send(compensate)
387
+ end
388
+ end
389
+ step.update!(state: "compensated")
390
+ end
391
+
392
+ persist_context(execution, workflow.ctx)
393
+ end
394
+
395
+ # Coordinates execution progression within a DB transaction.
396
+ class Processor
397
+ attr_reader :store, :execution
398
+
399
+ def initialize(store, execution)
400
+ @store = store
401
+ @execution = execution
402
+ end
403
+
404
+ def process!
405
+ if TERMINAL_STATES.include?(execution.state)
406
+ ActiveWorkflow.configuration.logger.debug("[ActiveWorkflow] process! early exit state=#{execution.state}")
407
+ ActiveSupport::Notifications.instrument("active_workflow.execution.#{execution.state}",
408
+ execution_id: execution.id,
409
+ workflow: execution.workflow_class)
410
+ return
411
+ end
412
+
413
+ check_timeouts!
414
+ step = next_step
415
+ if step.nil?
416
+ finalize_if_complete
417
+ return
418
+ end
419
+
420
+ workflow_class = execution.workflow_class.constantize
421
+ definition = workflow_class.step_definition(step.name)
422
+ ctx = store.context_from(execution)
423
+ workflow = workflow_class.new(context: ctx, execution_id: execution.id)
424
+
425
+ if skip_step?(workflow, definition)
426
+ mark_skipped(step, definition)
427
+ store.persist_context(execution, workflow.ctx)
428
+ ActiveSupport::Notifications.instrument("active_workflow.step.skipped",
429
+ execution_id: execution.id, step: definition.name, workflow: workflow_class.name)
430
+ process! # continue to next
431
+ return
432
+ end
433
+
434
+ attempt = step.attempts.to_i + 1
435
+ now = store.clock.call
436
+ step.update!(state: "running", attempts: attempt, started_at: now, scheduled_at: nil)
437
+ execution.update!(state: "running", cursor_step: definition.name)
438
+
439
+ ActiveSupport::Notifications.instrument("active_workflow.step.started",
440
+ execution_id: execution.id,
441
+ workflow: workflow_class.name,
442
+ step: definition.name,
443
+ attempt:) do
444
+ result = execute_step(workflow, definition, step)
445
+
446
+ case result
447
+ when :waiting
448
+ # Wait steps or async initialization indicated waiting state.
449
+ store.persist_context(execution, workflow.ctx)
450
+ when :complete
451
+ store.persist_context(execution, workflow.ctx)
452
+ step.update!(state: "completed", completed_at: store.clock.call)
453
+ ActiveSupport::Notifications.instrument("active_workflow.step.completed",
454
+ execution_id: execution.id, workflow: workflow_class.name, step: definition.name)
455
+ execution.update!(state: next_state_after(step), cursor_step: next_step_name(step))
456
+ process!
457
+ else
458
+ # Synchronous result returned.
459
+ store.persist_context(execution, workflow.ctx)
460
+ step.update!(state: "completed", completed_at: store.clock.call)
461
+ ActiveSupport::Notifications.instrument("active_workflow.step.completed",
462
+ execution_id: execution.id, workflow: workflow_class.name, step: definition.name, result: result)
463
+ execution.update!(state: next_state_after(step), cursor_step: next_step_name(step))
464
+ process!
465
+ end
466
+ end
467
+ rescue => error
468
+ handle_error(step, definition, workflow, error)
469
+ end
470
+
471
+ private
472
+
473
+ def check_timeouts!
474
+ now = store.clock.call
475
+
476
+ execution.steps.waiting.each do |step|
477
+ next unless step.timeout_at && step.timeout_at <= now
478
+
479
+ definition = step_definition(step)
480
+ ActiveWorkflow.configuration.logger.debug("[ActiveWorkflow] timeout step=#{step.name} attempts=#{step.attempts}")
481
+ ActiveSupport::Notifications.instrument("active_workflow.step.timeout",
482
+ execution_id: execution.id,
483
+ workflow: execution.workflow_class,
484
+ step: step.name)
485
+
486
+ current_attempts = step.attempts.to_i
487
+ step.update!(state: "timed_out", attempts: current_attempts)
488
+ store.schedule_retry_or_fail!(execution, step, definition)
489
+ end
490
+ end
491
+
492
+ def next_step
493
+ now = store.clock.call
494
+ execution.steps.ordered.detect do |step|
495
+ case step.state
496
+ when "pending"
497
+ !step.scheduled_at || step.scheduled_at <= now
498
+ when "running"
499
+ true
500
+ when "waiting"
501
+ wait_step_ready?(step)
502
+ else
503
+ false
504
+ end
505
+ end
506
+ end
507
+
508
+ def wait_step_ready?(step)
509
+ return false unless step.style == "wait"
510
+
511
+ execution.events.where(name: step.name, consumed_at: nil).exists?
512
+ end
513
+
514
+ def finalize_if_complete
515
+ if execution.steps.all? { |step| step.completed? || step.compensated? }
516
+ execution.update!(state: "completed", cursor_step: nil, completed_at: store.clock.call)
517
+ end
518
+ end
519
+
520
+ def execute_step(workflow, definition, step)
521
+ case definition.style
522
+ when :wait
523
+ event = execution.events.where(name: step.name, consumed_at: nil).order(:created_at).first
524
+ if event
525
+ consume_event(workflow, definition, event)
526
+ :complete
527
+ else
528
+ mark_waiting(step, definition, nil)
529
+ :waiting
530
+ end
531
+ else
532
+ result = workflow.call_step_callable(definition)
533
+ if async_step?(definition)
534
+ assign_async_init_result(workflow, definition, result)
535
+ mark_waiting(step, definition, result)
536
+ :waiting
537
+ else
538
+ assign_result(workflow, definition, result)
539
+ :complete
540
+ end
541
+ end
542
+ end
543
+
544
+ def consume_event(workflow, definition, event)
545
+ payload = event.payload
546
+ key = definition.options[:as]&.to_sym || definition.name
547
+ workflow.ctx[key] = payload
548
+ event.update!(consumed_at: store.clock.call)
549
+ end
550
+
551
+ def async_step?(definition)
552
+ definition.options[:async]
553
+ end
554
+
555
+ def assign_result(workflow, definition, result)
556
+ return if definition.options[:fire_and_forget]
557
+
558
+ key = definition.options[:store_result_as]&.to_sym || definition.name
559
+ workflow.ctx[key] = result unless result.nil?
560
+ end
561
+
562
+ def assign_async_init_result(workflow, definition, result)
563
+ return if definition.options[:fire_and_forget]
564
+
565
+ keys = [definition.name.to_sym]
566
+ store_result = definition.options[:store_result_as]&.to_sym
567
+ keys << store_result if store_result && store_result != definition.name.to_sym
568
+ keys.each do |key|
569
+ workflow.ctx[key] = result unless result.nil?
570
+ end
571
+ end
572
+
573
+ def mark_waiting(step, definition, init_result)
574
+ now = store.clock.call
575
+ timeout = definition.options[:timeout]
576
+ timeout_at = timeout ? now + timeout : nil
577
+ step.update!(
578
+ state: "waiting",
579
+ waiting_since: now,
580
+ timeout_at: timeout_at,
581
+ init_result: init_result
582
+ )
583
+ execution.update!(state: "waiting", cursor_step: definition.name)
584
+
585
+ ActiveSupport::Notifications.instrument("active_workflow.step.waiting",
586
+ execution_id: execution.id, workflow: execution.workflow_class, step: definition.name)
587
+ store.enqueue_runner(execution.id, run_at: timeout_at) if timeout_at
588
+ end
589
+
590
+ def mark_skipped(step, definition)
591
+ step.update!(state: "completed", completed_at: store.clock.call)
592
+ execution.update!(cursor_step: next_step_name(step), state: next_state_after(step))
593
+ end
594
+
595
+ def handle_error(step, definition, workflow, error)
596
+ attempts = step.attempts.to_i
597
+ step.update!(
598
+ state: "failed",
599
+ last_error_class: error.class.name,
600
+ last_error_message: error.message,
601
+ last_error_backtrace: error.backtrace&.take(20)&.join("\n"),
602
+ attempts: attempts
603
+ )
604
+
605
+ ActiveSupport::Notifications.instrument("active_workflow.step.failed",
606
+ execution_id: execution.id,
607
+ workflow: execution.workflow_class,
608
+ step: definition.name,
609
+ error_class: error.class.name,
610
+ message: error.message)
611
+
612
+ store.persist_context(execution, workflow.ctx)
613
+ store.schedule_retry_or_fail!(execution, step, definition)
614
+ end
615
+
616
+ def skip_step?(workflow, definition)
617
+ if_condition = definition.options[:if]
618
+ unless_condition = definition.options[:unless]
619
+
620
+ if if_condition
621
+ result = evaluate_condition(workflow, if_condition)
622
+ return true unless result
623
+ end
624
+
625
+ if unless_condition
626
+ result = evaluate_condition(workflow, unless_condition)
627
+ return true if result
628
+ end
629
+
630
+ false
631
+ end
632
+
633
+ def evaluate_condition(workflow, condition)
634
+ case condition
635
+ when Symbol
636
+ workflow.send(condition)
637
+ when Proc
638
+ workflow.instance_exec(workflow.ctx, &condition)
639
+ else
640
+ !!condition
641
+ end
642
+ end
643
+
644
+ def next_step_name(step)
645
+ next_step = execution.steps.ordered.where("position > ?", step.position).first
646
+ next_step&.name
647
+ end
648
+
649
+ def next_state_after(step)
650
+ next_step = execution.steps.ordered.where("position > ?", step.position).first
651
+ return "completed" unless next_step
652
+
653
+ next_step.waiting? ? "waiting" : "running"
654
+ end
655
+
656
+ def step_definition(step)
657
+ execution.workflow_class.constantize.step_definition(step.name)
658
+ end
659
+ end
660
+
661
+ class Backoff
662
+ class << self
663
+ def calculate(config, attempts:)
664
+ strategy = (config[:strategy] || config[:backoff] || :exponential).to_sym
665
+ base = base_delay(config, attempts, strategy)
666
+ config[:jitter] ? apply_jitter(base) : base
667
+ end
668
+
669
+ private
670
+
671
+ def base_delay(config, attempts, strategy)
672
+ first = config[:first_delay]&.to_f || config[:delay]&.to_f || 5.0
673
+ delay = config[:delay]&.to_f || first
674
+
675
+ case strategy
676
+ when :fixed
677
+ attempts <= 1 ? first : delay
678
+ else
679
+ return first if attempts <= 1
680
+
681
+ exponent = attempts - 1
682
+ delay * (2**exponent)
683
+ end
684
+ end
685
+
686
+ def apply_jitter(value)
687
+ low = value * 0.5
688
+ high = value * 1.5
689
+ rand(low..high)
690
+ end
691
+ end
692
+ end
693
+ end
694
+ end
695
+ end