blood_contracts-instrumentation 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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BloodContracts
4
+ module Instrumentation
5
+ # Wrapper for exception happend during the match instrumentation
6
+ # Should not be used in the app, to distinguish between expected and
7
+ # unexpected failures
8
+ class FailedMatch < ::BC::ContractFailure
9
+ # Initialize failure type with exception
10
+ #
11
+ # @param value [Exception] rescued exception from the type match
12
+ # @option context [Hash] shared context of matching pipeline
13
+ #
14
+ # @return [FailedMatch]
15
+ #
16
+ def initialize(exception, context: {})
17
+ @errors = []
18
+ @context = context
19
+ @value = exception
20
+ @context[:exception] = exception
21
+ end
22
+
23
+ # Predicate, whether the data is valid or not
24
+ # (for the ExceptionCaught it is always False)
25
+ #
26
+ # @return [Boolean]
27
+ #
28
+ def valid?
29
+ false
30
+ end
31
+
32
+ # Reader for the exception caught
33
+ #
34
+ # @return [Exception]
35
+ #
36
+ def exception
37
+ @context[:exception]
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BloodContracts
4
+ module Instrumentation
5
+ # Base class for instrumentation tooling
6
+ class Instrument
7
+ class << self
8
+ # Builds an Instrument class from the proto and before/after callbacks
9
+ #
10
+ # When `proto` is just a Proc - we create new Instrument class around
11
+ # Otherwise - use the `proto` object as an instrument
12
+ #
13
+ # Also if before/after is defined we add the definition to the `proto`
14
+ #
15
+ # @param proto [#call, Proc] callable object that is used as an
16
+ # instrumentation tool
17
+ # @option before [#call, Proc] definition of before callback, it runs
18
+ # right after Session#start in the matching pipeline, the argument
19
+ # is Session instance for current BC::Refined#match call
20
+ # @option before [#call, Proc] definition of before callback, it runs
21
+ # right after Session#finish in the matching pipeline, the argument
22
+ # is Session instance for current BC::Refined#match call
23
+ #
24
+ # @return [Instrument, #call]
25
+ #
26
+ def build(proto, before: nil, after: nil)
27
+ raise ArgumentError unless proto.respond_to?(:call)
28
+
29
+ instance = instrument_from_proc(proto)
30
+
31
+ if before.respond_to?(:call)
32
+ inst.define_singleton_method(:before, &before)
33
+ end
34
+
35
+ define_stub(instance, :before)
36
+
37
+ if after.respond_to?(:call)
38
+ instance.define_singleton_method(:after, &after)
39
+ end
40
+
41
+ define_stub(instance, :after)
42
+
43
+ instance
44
+ end
45
+
46
+ private def define_stub(instance, name)
47
+ return if instance.respond_to?(name)
48
+
49
+ instance.define_singleton_method(name) { |_| }
50
+ end
51
+
52
+ # @private
53
+ private def instrument_from_proc(proto)
54
+ return proto unless proto.is_a?(Proc)
55
+
56
+ inst_klass = Class.new(self)
57
+ inst_klass.define_method(:call, &proto)
58
+ const_set(:"I_#{SecureRandom.hex(4)}", inst_klass)
59
+ inst_klass.new
60
+ end
61
+ end
62
+
63
+ # Predefined interface for Instrument before callback, do-no
64
+ #
65
+ # @param _session [Session] to use in callback
66
+ #
67
+ # @return [Nothing]
68
+ #
69
+ def before(_session); end
70
+
71
+ # Predefined interface for Instrument after callback, do-no
72
+ #
73
+ # @param _session [Session] to use in callback
74
+ #
75
+ # @return [Nothing]
76
+ #
77
+ def after(_session); end
78
+
79
+ # Predefined interface for Instrument finalization call, do-no
80
+ #
81
+ # @param _session [Session] to use in callback
82
+ #
83
+ # @return [Nothing]
84
+ #
85
+ def call(_session); end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BloodContracts
4
+ module Instrumentation
5
+ # Basic class to hold data about matching process
6
+ # Start date, finish date, result type name and the validation context
7
+ class Session
8
+ # Unique ID of the session
9
+ #
10
+ # @return [String]
11
+ #
12
+ attr_reader :id
13
+
14
+ # Time when session started
15
+ #
16
+ # @return [Time]
17
+ #
18
+ attr_reader :started_at
19
+
20
+ # Time when session finished
21
+ #
22
+ # @return [Time]
23
+ #
24
+ attr_reader :finished_at
25
+
26
+ # Frozen hash of matching pipeline context
27
+ #
28
+ # @return [Hash]
29
+ #
30
+ attr_reader :context
31
+
32
+ # Additional text about scope of the mathc (e.g. "User:12")
33
+ #
34
+ # @return [String]
35
+ #
36
+ attr_reader :scope
37
+
38
+ # Name of the type which owns the session
39
+ #
40
+ # @return [String]
41
+ #
42
+ attr_reader :matcher_type_name
43
+
44
+ # Name of the matching result type
45
+ #
46
+ # @return [String]
47
+ #
48
+ attr_reader :result_type_name
49
+
50
+ # List of matches in the pipeline run
51
+ #
52
+ # @return [Array<String>]
53
+ #
54
+ attr_reader :path
55
+
56
+ # Additional data for instrumentaion stores here
57
+ #
58
+ # @return [Hash]
59
+ #
60
+ attr_reader :extras
61
+
62
+ # Whether the result was valid or not
63
+ #
64
+ # @return [Boolean]
65
+ #
66
+ def valid?
67
+ !!@valid
68
+ end
69
+
70
+ # Initialize the session with matcher type name with defaults
71
+ #
72
+ # @param type_name [String] name of the type which owns the session
73
+ #
74
+ # @return [Nothing]
75
+ #
76
+ def initialize(type_name)
77
+ @id = SecureRandom.hex(10)
78
+ @matcher_type_name = type_name
79
+ @extras = {}
80
+ @context = {}
81
+ end
82
+
83
+ # Marks the session as started
84
+ # If you inherit the Session this method runs right BEFORE matching start
85
+ #
86
+ # @return [Nothing]
87
+ #
88
+ def start
89
+ @started_at = Time.now
90
+ end
91
+
92
+ # Session scope fallback
93
+ NO_SCOPE = "unscoped"
94
+
95
+ # Session validation path fallback
96
+ NO_VALIDATION_PATH = "undefined"
97
+
98
+ # Session result type name fallback
99
+ NO_TYPE_MATCH = "unmatched"
100
+
101
+ # Marks the session as finished (with the type)
102
+ # If you inherit the Session this method runs right AFTER matching
103
+ # finished (even if an exception raised the method would be called)
104
+ #
105
+ # @param type_match [BC::Refined] result type of matching pipeline
106
+ #
107
+ # @return [Nothing]
108
+ #
109
+ def finish(type_match)
110
+ @finished_at = Time.now
111
+ @context = type_match.context.dup.freeze if type_match
112
+ @valid = type_match&.valid?
113
+
114
+ @result_type_name = type_match&.class&.name || NO_TYPE_MATCH
115
+ @id = @context.fetch(:session_id) { @id }
116
+ @scope = @context.fetch(:scope) { NO_SCOPE }
117
+ @path = @context.fetch(:steps) { NO_VALIDATION_PATH }
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BloodContracts
4
+ module Instrumentation
5
+ # Top-level interface for Instrument finalizers
6
+ module SessionFinalizer
7
+ module_function
8
+
9
+ require_relative "./session_finalizer/basic.rb"
10
+ require_relative "./session_finalizer/fibers.rb"
11
+ require_relative "./session_finalizer/threads.rb"
12
+
13
+ # Names of finalizers
14
+ #
15
+ # @return [Array<Symbol>]
16
+ #
17
+ FINALIZERS = %i[basic fibers threads].freeze
18
+
19
+ # @private
20
+ WRONG_FINALIZER_MSG = "Choose finalizer wisely: #{FINALIZERS.join(', ')}"
21
+
22
+ # @private
23
+ DEFAULT_POOL_SIZE = 13
24
+
25
+ # Current thread instance of the Session finalizer
26
+ #
27
+ # @return [#finalize!]
28
+ #
29
+ def instance
30
+ Thread.current[:bc_session_finalizer] ||=
31
+ Instrumentation.reset_session_finalizer!
32
+ end
33
+
34
+ # Reset the finalizer by name
35
+ #
36
+ # @param name [Symbol] finalizer to find
37
+ # @param **opts [Hash] options passed to finalizer constructor
38
+ #
39
+ # @return [#finalize!]
40
+ #
41
+ def init(name, **opts)
42
+ Thread.current[:bc_session_finalizer] = find_finalizer_by(name, **opts)
43
+ end
44
+
45
+ # @private
46
+ private def find_finalizer_by(name, pool_size: DEFAULT_POOL_SIZE)
47
+ case name
48
+ when :basic
49
+ Basic
50
+ when :fibers
51
+ Fibers.new(pool_size)
52
+ when :threads
53
+ Threads
54
+ else
55
+ raise ArgumentError, WRONG_FINALIZER_MSG
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BloodContracts
4
+ module Instrumentation
5
+ module SessionFinalizer
6
+ # Basic implementation of Session finaliazer
7
+ module Basic
8
+ # Run the instruments against the session in a loop
9
+ # Pros:
10
+ # - simplest, obvious logic
11
+ # Cons:
12
+ # - failure in one instrument affects the others
13
+ #
14
+ # @param instruments [Array<Instrument>] list of Instruments to run
15
+ # against the session
16
+ # @param session [Session] object that hold information about matching
17
+ # process, argument for Instrument#call
18
+ #
19
+ # @return [Nothing]
20
+ #
21
+ def self.finalize!(instruments, session)
22
+ instruments.each { |i| i.call(session) }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiber"
4
+
5
+ module BloodContracts
6
+ module Instrumentation
7
+ module SessionFinalizer
8
+ # Threads over fibers implementation of Session finaliazer
9
+ class Fibers
10
+ # Error message when fibers pool is not enough to run all the
11
+ # instruments
12
+ STARVATION_MSG = "WARNING! BC::Instrumentation fiber starvation!"
13
+
14
+ # Pool of Fibers to finalize instrumentation session
15
+ #
16
+ # @return [Array<Fiber>]
17
+ #
18
+ attr_reader :fibers
19
+
20
+ # Initialize the fibers pool
21
+ #
22
+ # @param pool_size [Integer] number of fibers to use in a single run
23
+ def initialize(pool_size)
24
+ @fibers = pool_size.times.map { create_fiber_with_a_thread }
25
+ end
26
+
27
+ # Run the instruments against the session in a loop (each in a separate
28
+ # fiber on separate thread)
29
+ #
30
+ # Pros:
31
+ # - Each instrument call don't affect the others
32
+ # - Each instrument run in parallel (up to GIL)
33
+ # - Runs in parallel to the matching process, so should have
34
+ # minimum impact on BC::Refined#match speed
35
+ # Cons:
36
+ # - thread creation have costs
37
+ # - the pool size is limited, so if you use number of instruments
38
+ # more the pool size you have to update Config#finalizer_pool_size
39
+ # properly
40
+ #
41
+ # @param instruments [Array<Instrument>] list of Instruments to run
42
+ # against the session
43
+ # @param session [Session] object that hold information about matching
44
+ # process, argument for Instrument#call
45
+ #
46
+ # @return [Nothing]
47
+ #
48
+ def finalize!(instruments, session)
49
+ instruments.each do |instrument|
50
+ raise STARVATION_MSG unless (fiber = @fibers.shift)
51
+
52
+ fiber.resume instrument, session if fiber.alive?
53
+ end
54
+ end
55
+
56
+ # @private
57
+ #
58
+ # Create a fiber which holds a Thread to run next Instrument#call
59
+ # against the session
60
+ #
61
+ # @return [Fiber]
62
+ protected def create_fiber_with_a_thread
63
+ Fiber.new do |instrument, session|
64
+ loop do
65
+ thread = Thread.new(instrument, session) { |i, s| i.call(s) }
66
+ @fibers.unshift Fiber.current
67
+ instrument, session = Fiber.yield
68
+ thread.join
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BloodContracts
4
+ module Instrumentation
5
+ module SessionFinalizer
6
+ # Multi-threaded implementation of Session finalizer
7
+ module Threads
8
+ # Run the instruments against the session in a Thread in a loop
9
+ # Pros:
10
+ # - parallel execution of instruments (up to GIL)
11
+ # - one failed instrument do not affect the others
12
+ # Cons:
13
+ # - creating a thread has costs
14
+ # - do not parallel with the BC::Refined matching, as we need to
15
+ # finish the threads, join them to main Thread
16
+ #
17
+ # @param instruments [Array<Instrument>] list of Instruments to run
18
+ # against the session
19
+ # @param session [Session] object that hold information about matching
20
+ # process, argument for Instrument#call
21
+ #
22
+ # @return [Nothing]
23
+ #
24
+ def self.finalize!(instruments, session)
25
+ threads = instruments.map do |i|
26
+ Thread.new { i.call(session) }
27
+ end
28
+ threads.map(&:join)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end