senro_usecaser 0.3.0 → 0.4.1

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.
@@ -121,44 +121,13 @@ module SenroUsecaser
121
121
  # organize StepA, StepB
122
122
  # end
123
123
  class Base
124
- class << self
125
- # Declares a dependency to be injected from the container
126
- #
127
- #: (Symbol, ?Class) -> void
128
- def depends_on(name, type = nil)
129
- dependencies << name unless dependencies.include?(name)
130
- dependency_types[name] = type if type
131
-
132
- define_method(name) do
133
- @_dependencies[name]
134
- end
135
- end
136
-
137
- # Returns the list of declared dependencies
138
- #
139
- #: () -> Array[Symbol]
140
- def dependencies
141
- @dependencies ||= []
142
- end
143
-
144
- # Returns the dependency type mapping
145
- #
146
- #: () -> Hash[Symbol, Class]
147
- def dependency_types
148
- @dependency_types ||= {}
149
- end
124
+ extend DependsOn
150
125
 
151
- # Sets the namespace for dependency resolution
152
- #
153
- #: ((Symbol | String)) -> void
154
- def namespace(name)
155
- @use_case_namespace = name
156
- end
157
-
158
- # Returns the declared namespace
126
+ class << self
127
+ # Alias for backward compatibility
159
128
  #
160
129
  #: () -> (Symbol | String)?
161
- attr_reader :use_case_namespace
130
+ alias use_case_namespace declared_namespace
162
131
 
163
132
  # Declares a sequence of UseCases to execute as a pipeline
164
133
  #
@@ -276,6 +245,101 @@ module SenroUsecaser
276
245
  @around_hooks ||= []
277
246
  end
278
247
 
248
+ # Adds an on_failure hook
249
+ #
250
+ #: () { (untyped, Result[untyped], ?RetryContext?) -> void } -> void
251
+ def on_failure(&block)
252
+ on_failure_hooks << block
253
+ end
254
+
255
+ # Returns the list of on_failure hooks
256
+ #
257
+ #: () -> Array[Proc]
258
+ def on_failure_hooks
259
+ @on_failure_hooks ||= []
260
+ end
261
+
262
+ # Configures automatic retry for specific error types
263
+ #
264
+ # @example Retry on network errors
265
+ # retry_on :network_error, attempts: 3, wait: 1
266
+ #
267
+ # @example Retry on exception class
268
+ # retry_on Net::OpenTimeout, attempts: 5, wait: 2, backoff: :exponential
269
+ #
270
+ # @example Multiple error types with jitter
271
+ # retry_on :rate_limited, :timeout, attempts: 3, wait: 1, jitter: 0.1
272
+ #
273
+ # rubocop:disable Metrics/ParameterLists
274
+ #: (*(Symbol | Class), ?attempts: Integer, ?wait: (Float | Integer),
275
+ #: ?backoff: Symbol, ?max_wait: (Float | Integer)?, ?jitter: (Float | Integer)) -> void
276
+ def retry_on(*error_matchers, attempts: 3, wait: 0, backoff: :fixed, max_wait: nil, jitter: 0)
277
+ retry_configurations << RetryConfiguration.new(
278
+ matchers: error_matchers.flatten,
279
+ attempts: attempts,
280
+ wait: wait,
281
+ backoff: backoff,
282
+ max_wait: max_wait,
283
+ jitter: jitter
284
+ )
285
+ end
286
+ # rubocop:enable Metrics/ParameterLists
287
+
288
+ # Returns the list of retry configurations
289
+ #
290
+ #: () -> Array[RetryConfiguration]
291
+ def retry_configurations
292
+ @retry_configurations ||= []
293
+ end
294
+
295
+ # Configures errors that should immediately discard (no retry)
296
+ #
297
+ # @example Discard on validation errors
298
+ # discard_on :validation_error, :not_found
299
+ #
300
+ # @example Discard on exception class
301
+ # discard_on ArgumentError
302
+ #
303
+ #: (*(Symbol | Class)) -> void
304
+ def discard_on(*error_matchers)
305
+ discard_matchers.concat(error_matchers.flatten)
306
+ end
307
+
308
+ # Returns the list of discard matchers
309
+ #
310
+ #: () -> Array[(Symbol | Class)]
311
+ def discard_matchers
312
+ @discard_matchers ||= []
313
+ end
314
+
315
+ # Adds a before_retry hook
316
+ #
317
+ #: () { (untyped, Result[untyped], RetryContext) -> void } -> void
318
+ def before_retry(&block)
319
+ before_retry_hooks << block
320
+ end
321
+
322
+ # Returns the list of before_retry hooks
323
+ #
324
+ #: () -> Array[Proc]
325
+ def before_retry_hooks
326
+ @before_retry_hooks ||= []
327
+ end
328
+
329
+ # Adds an after_retries_exhausted hook
330
+ #
331
+ #: () { (untyped, Result[untyped], RetryContext) -> void } -> void
332
+ def after_retries_exhausted(&block)
333
+ after_retries_exhausted_hooks << block
334
+ end
335
+
336
+ # Returns the list of after_retries_exhausted hooks
337
+ #
338
+ #: () -> Array[Proc]
339
+ def after_retries_exhausted_hooks
340
+ @after_retries_exhausted_hooks ||= []
341
+ end
342
+
279
343
  # Declares the expected input type(s) for this UseCase
280
344
  # Accepts a Class or one or more Modules that input must include
281
345
  #
@@ -359,13 +423,13 @@ module SenroUsecaser
359
423
  private
360
424
 
361
425
  def copy_configuration_to(subclass)
362
- subclass.instance_variable_set(:@dependencies, dependencies.dup)
363
- subclass.instance_variable_set(:@dependency_types, dependency_types.dup)
364
- subclass.instance_variable_set(:@use_case_namespace, @use_case_namespace)
426
+ copy_depends_on_to(subclass)
365
427
  subclass.instance_variable_set(:@organized_steps, @organized_steps&.dup)
366
428
  subclass.instance_variable_set(:@on_failure_strategy, @on_failure_strategy)
367
429
  subclass.instance_variable_set(:@input_types, @input_types&.dup)
368
430
  subclass.instance_variable_set(:@output_schema, @output_schema)
431
+ subclass.instance_variable_set(:@retry_configurations, retry_configurations.dup)
432
+ subclass.instance_variable_set(:@discard_matchers, discard_matchers.dup)
369
433
  end
370
434
 
371
435
  def copy_hooks_to(subclass)
@@ -373,6 +437,9 @@ module SenroUsecaser
373
437
  subclass.instance_variable_set(:@before_hooks, before_hooks.dup)
374
438
  subclass.instance_variable_set(:@after_hooks, after_hooks.dup)
375
439
  subclass.instance_variable_set(:@around_hooks, around_hooks.dup)
440
+ subclass.instance_variable_set(:@on_failure_hooks, on_failure_hooks.dup)
441
+ subclass.instance_variable_set(:@before_retry_hooks, before_retry_hooks.dup)
442
+ subclass.instance_variable_set(:@after_retries_exhausted_hooks, after_retries_exhausted_hooks.dup)
376
443
  end
377
444
  end
378
445
 
@@ -397,10 +464,7 @@ module SenroUsecaser
397
464
  end
398
465
 
399
466
  validate_input!(input)
400
-
401
- execute_with_hooks(input) do
402
- call(input)
403
- end
467
+ execute_with_retry(input)
404
468
  end
405
469
 
406
470
  # Executes the UseCase logic
@@ -412,6 +476,9 @@ module SenroUsecaser
412
476
  raise NotImplementedError, "#{self.class.name}#call must be implemented"
413
477
  end
414
478
 
479
+ # Represents a record of a step execution in a pipeline
480
+ StepExecutionRecord = Struct.new(:step, :input, :result, keyword_init: true)
481
+
415
482
  private
416
483
 
417
484
  # Creates a success Result with the given value
@@ -496,6 +563,90 @@ module SenroUsecaser
496
563
  result
497
564
  end
498
565
 
566
+ # Executes the UseCase with retry support
567
+ #
568
+ #: (untyped) -> Result[untyped]
569
+ def execute_with_retry(input)
570
+ context = build_retry_context
571
+ current_input = input
572
+
573
+ loop do
574
+ result = execute_with_hooks(current_input) { call(current_input) }
575
+
576
+ return result if result.success?
577
+ return result if should_discard?(result)
578
+
579
+ retry_config = find_matching_retry_config(result)
580
+ run_on_failure_hooks(current_input, result, context)
581
+
582
+ should_retry = context.should_retry? || (retry_config && !context.exhausted?)
583
+
584
+ unless should_retry
585
+ run_after_retries_exhausted_hooks(current_input, result, context) if context.retried?
586
+ return result
587
+ end
588
+
589
+ wait_time = context.retry_wait || retry_config&.calculate_wait(context.attempt) || 0
590
+ run_before_retry_hooks(current_input, result, context)
591
+
592
+ sleep(wait_time) if wait_time.positive?
593
+
594
+ current_input = context.retry_input || current_input
595
+ context.increment!(last_error: result.errors.first)
596
+ end
597
+ end
598
+
599
+ # Builds a retry context with max attempts from configurations
600
+ #
601
+ #: () -> RetryContext
602
+ def build_retry_context
603
+ max_attempts = self.class.retry_configurations.map(&:attempts).max
604
+ RetryContext.new(max_attempts: max_attempts)
605
+ end
606
+
607
+ # Finds a retry configuration that matches the result
608
+ #
609
+ #: (Result[untyped]) -> RetryConfiguration?
610
+ def find_matching_retry_config(result)
611
+ self.class.retry_configurations.find { |c| c.matches?(result) }
612
+ end
613
+
614
+ # Checks if the result should be discarded (no retry)
615
+ #
616
+ #: (Result[untyped]) -> bool
617
+ def should_discard?(result)
618
+ return false unless result.failure?
619
+
620
+ result.errors.any? do |error|
621
+ self.class.discard_matchers.any? do |matcher|
622
+ case matcher
623
+ when Symbol
624
+ error.code == matcher
625
+ when Class
626
+ error.cause&.is_a?(matcher)
627
+ end
628
+ end
629
+ end
630
+ end
631
+
632
+ # Runs before_retry hooks
633
+ #
634
+ #: (untyped, Result[untyped], RetryContext) -> void
635
+ def run_before_retry_hooks(input, result, context)
636
+ self.class.before_retry_hooks.each do |hook|
637
+ instance_exec(input, result, context, &hook) # steep:ignore BlockTypeMismatch
638
+ end
639
+ end
640
+
641
+ # Runs after_retries_exhausted hooks
642
+ #
643
+ #: (untyped, Result[untyped], RetryContext) -> void
644
+ def run_after_retries_exhausted_hooks(input, result, context)
645
+ self.class.after_retries_exhausted_hooks.each do |hook|
646
+ instance_exec(input, result, context, &hook) # steep:ignore BlockTypeMismatch
647
+ end
648
+ end
649
+
499
650
  # Wraps a non-Result value in Result.success
500
651
  #
501
652
  #: (untyped) -> Result[untyped]
@@ -576,6 +727,44 @@ module SenroUsecaser
576
727
  self.class.after_hooks.each { |hook| instance_exec(input, result, &hook) } # steep:ignore BlockTypeMismatch
577
728
  end
578
729
 
730
+ # Runs all on_failure hooks when result is a failure
731
+ #
732
+ #: (untyped, Result[untyped], ?RetryContext?) -> void
733
+ def run_on_failure_hooks(input, result, context = nil)
734
+ return unless result.failure?
735
+
736
+ hook_instances.each do |hook_instance|
737
+ call_on_failure_hook(hook_instance, :on_failure, input, result, context)
738
+ end
739
+
740
+ self.class.extensions.each do |ext|
741
+ next if hook_class?(ext)
742
+ next unless ext.respond_to?(:on_failure)
743
+
744
+ call_on_failure_hook(ext, :on_failure, input, result, context)
745
+ end
746
+
747
+ self.class.on_failure_hooks.each do |hook|
748
+ if context && (hook.arity == 3 || hook.arity.negative?)
749
+ instance_exec(input, result, context, &hook) # steep:ignore BlockTypeMismatch
750
+ else
751
+ instance_exec(input, result, &hook) # steep:ignore BlockTypeMismatch
752
+ end
753
+ end
754
+ end
755
+
756
+ # Calls an on_failure hook with appropriate arguments
757
+ #
758
+ #: (untyped, Symbol, untyped, Result[untyped], RetryContext?) -> void
759
+ def call_on_failure_hook(target, method_name, input, result, context)
760
+ method = target.method(method_name)
761
+ if context && (method.arity == 3 || method.arity.negative?)
762
+ target.send(method_name, input, result, context)
763
+ else
764
+ target.send(method_name, input, result)
765
+ end
766
+ end
767
+
579
768
  # Returns instantiated hook objects
580
769
  #
581
770
  #: () -> Array[Hook]
@@ -609,43 +798,18 @@ module SenroUsecaser
609
798
  end
610
799
 
611
800
  # Resolves a single dependency from the container
801
+ # Overrides DependsOn::InstanceMethods to accept container as parameter
612
802
  #
613
803
  #: (Container, Symbol) -> untyped
614
804
  def resolve_from_container(container, name)
615
- namespace = effective_namespace
616
- if namespace
617
- container.resolve_in(namespace, name)
805
+ ns = effective_namespace
806
+ if ns
807
+ container.resolve_in(ns, name)
618
808
  else
619
809
  container.resolve(name)
620
810
  end
621
811
  end
622
812
 
623
- # Returns the effective namespace for dependency resolution
624
- #
625
- #: () -> (Symbol | String)?
626
- def effective_namespace
627
- return self.class.use_case_namespace if self.class.use_case_namespace
628
- return nil unless SenroUsecaser.configuration.infer_namespace_from_module
629
-
630
- infer_namespace_from_class
631
- end
632
-
633
- # Infers namespace from the class's module structure
634
- #
635
- #: () -> String?
636
- def infer_namespace_from_class
637
- class_name = self.class.name
638
- return nil unless class_name
639
-
640
- parts = class_name.split("::")
641
- return nil if parts.length <= 1
642
-
643
- module_parts = parts[0...-1] || [] #: Array[String]
644
- return nil if module_parts.empty?
645
-
646
- module_parts.map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase }.join("::")
647
- end
648
-
649
813
  # Executes the organized UseCase pipeline
650
814
  #
651
815
  #: (untyped) -> Result[untyped]
@@ -668,12 +832,18 @@ module SenroUsecaser
668
832
  def execute_pipeline_stop(input)
669
833
  current_input = input
670
834
  result = nil #: Result[untyped]?
835
+ executed_steps = [] #: Array[StepExecutionRecord]
671
836
 
672
837
  self.class.organized_steps&.each do |step|
673
838
  next unless step.should_execute?(current_input, self)
674
839
 
675
840
  step_result = execute_step(step, current_input)
676
- return step_result if step_result.failure? && step_should_stop?(step)
841
+ executed_steps << StepExecutionRecord.new(step: step, input: current_input, result: step_result)
842
+
843
+ if step_result.failure? && step_should_stop?(step)
844
+ execute_pipeline_rollback(executed_steps)
845
+ return step_result
846
+ end
677
847
 
678
848
  current_input = step_result.value if step_result.success?
679
849
  result = step_result
@@ -688,12 +858,18 @@ module SenroUsecaser
688
858
  def execute_pipeline_continue(input)
689
859
  current_input = input
690
860
  result = nil #: Result[untyped]?
861
+ executed_steps = [] #: Array[StepExecutionRecord]
691
862
 
692
863
  self.class.organized_steps&.each do |step|
693
864
  next unless step.should_execute?(current_input, self)
694
865
 
695
866
  step_result = execute_step(step, current_input)
696
- return step_result if step_result.failure? && step.on_failure == :stop
867
+ executed_steps << StepExecutionRecord.new(step: step, input: current_input, result: step_result)
868
+
869
+ if step_result.failure? && step.on_failure == :stop
870
+ execute_pipeline_rollback(executed_steps)
871
+ return step_result
872
+ end
697
873
 
698
874
  current_input = step_result.value if step_result.success?
699
875
  result = step_result
@@ -707,16 +883,20 @@ module SenroUsecaser
707
883
  #: (untyped) -> Result[untyped]
708
884
  def execute_pipeline_collect(input)
709
885
  errors = [] #: Array[Error]
886
+ executed_steps = [] #: Array[StepExecutionRecord]
710
887
  state = { input: input, errors: errors, last_success: nil }
711
888
 
712
889
  self.class.organized_steps&.each do |step|
713
890
  next unless step.should_execute?(state[:input], self)
714
891
 
715
892
  result = execute_step(step, state[:input])
893
+ executed_steps << StepExecutionRecord.new(step: step, input: state[:input], result: result)
716
894
  break if should_stop_collect_pipeline?(result, step, state)
717
895
  end
718
896
 
719
- build_collect_result(state)
897
+ final_result = build_collect_result(state)
898
+ execute_pipeline_rollback(executed_steps) if final_result.failure?
899
+ final_result
720
900
  end
721
901
 
722
902
  # Updates collect state and checks if pipeline should stop
@@ -762,6 +942,7 @@ module SenroUsecaser
762
942
 
763
943
  # Calls a single UseCase in the pipeline
764
944
  # Requires input type(s) to be defined for pipeline steps
945
+ # Note: on_failure hooks are not called here - they're called in pipeline rollback
765
946
  #
766
947
  #: (singleton(Base), untyped) -> Result[untyped]
767
948
  def call_use_case(use_case_class, input)
@@ -769,8 +950,59 @@ module SenroUsecaser
769
950
  raise ArgumentError, "#{use_case_class.name} must define `input` type(s) to be used in a pipeline"
770
951
  end
771
952
 
772
- call_method = @_capture_exceptions || false ? :call! : :call #: Symbol
773
- use_case_class.public_send(call_method, input, container: @_container)
953
+ instance = use_case_class.new(container: @_container)
954
+ instance.send(:perform_as_pipeline_step, input, capture_exceptions: @_capture_exceptions || false)
955
+ end
956
+
957
+ # Performs the UseCase as a pipeline step (without on_failure hooks)
958
+ # on_failure hooks are handled by the pipeline's rollback mechanism instead
959
+ #
960
+ #: (untyped, ?capture_exceptions: bool) -> Result[untyped]
961
+ def perform_as_pipeline_step(input, capture_exceptions: false)
962
+ @_capture_exceptions = capture_exceptions
963
+
964
+ unless self.class.input_class || self.class.organized_steps
965
+ raise ArgumentError, "#{self.class.name} must define `input` class"
966
+ end
967
+
968
+ validate_input!(input)
969
+ execute_with_hooks(input) { call(input) }
970
+ rescue StandardError => e
971
+ raise unless capture_exceptions
972
+
973
+ Result.from_exception(e)
974
+ end
975
+
976
+ # Executes rollback by calling on_failure hooks on executed steps in reverse order
977
+ # Unlike run_on_failure_hooks, this method calls hooks regardless of result status
978
+ # because we want to rollback even successfully completed steps when pipeline fails
979
+ #
980
+ #: (Array[StepExecutionRecord]) -> void
981
+ def execute_pipeline_rollback(executed_steps)
982
+ executed_steps.reverse_each do |record|
983
+ step_instance = record.step.use_case_class.new(container: @_container)
984
+ step_instance.send(:run_rollback_hooks, record.input, record.result)
985
+ end
986
+ end
987
+
988
+ # Runs on_failure hooks for rollback purposes (regardless of result status)
989
+ #
990
+ #: (untyped, Result[untyped]) -> void
991
+ def run_rollback_hooks(input, result)
992
+ hook_instances.each do |hook_instance|
993
+ call_on_failure_hook(hook_instance, :on_failure, input, result, nil)
994
+ end
995
+
996
+ self.class.extensions.each do |ext|
997
+ next if hook_class?(ext)
998
+ next unless ext.respond_to?(:on_failure)
999
+
1000
+ call_on_failure_hook(ext, :on_failure, input, result, nil)
1001
+ end
1002
+
1003
+ self.class.on_failure_hooks.each do |hook|
1004
+ instance_exec(input, result, &hook) # steep:ignore BlockTypeMismatch
1005
+ end
774
1006
  end
775
1007
  end
776
1008
  end