aeternitas 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,18 @@
1
+ module Aeternitas
2
+ module Metrics
3
+ # Wrapper for {TabsTabs::Metrics::Counter::Stats}.
4
+ # It is for Counter metrics.
5
+ class Counter
6
+ include Enumerable
7
+ extend Forwardable
8
+
9
+ def_delegators :@tabstabs_stats, :min, :max, :avg, :each, :to_a, :first, :last
10
+
11
+ # Create a new Wrapper
12
+ # @param [TabsTabs::Metrics::Counter::Stats] tabstabs_stats the wrapped stats.
13
+ def initialize(tabstabs_stats)
14
+ @tabstabs_stats = tabstabs_stats
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,67 @@
1
+ module Aeternitas
2
+ module Metrics
3
+ # Stores time series data for ratios
4
+ # @!attribute [r] from
5
+ # @return [DateTime] start of the time series
6
+ # @!attribute [r] to
7
+ # @return [DateTime] end of the time series
8
+ # @!attribute [r] resolution
9
+ # @return [Symbol] resolution of the time series
10
+ # @!attribute [r] values
11
+ # @return [Array] time series data
12
+ # The time series values have the following format:
13
+ # {
14
+ # timestamp: DateTime("2000-01-01 00:00:00 UTC"),
15
+ # ratio: 0.01
16
+ # }
17
+ class Ratio
18
+ include Enumerable
19
+
20
+ attr_reader :from, :to, :resolution, :values
21
+
22
+ # Create a new ratio time series
23
+ # @param [DateTime] from start of the time series
24
+ # @param [DateTime] to end of the time series
25
+ # @param [Symbol] resolution time series resolution
26
+ # @param [Array] values time series data
27
+ def initialize(from, to, resolution, values)
28
+ @from = from
29
+ @to = to
30
+ @resolution = resolution
31
+ @values = values
32
+ end
33
+
34
+ def each(&block)
35
+ @values.each(&block)
36
+ end
37
+
38
+ def to_a
39
+ @values.to_a
40
+ end
41
+
42
+ # Computes the minimum ration within the time series.
43
+ # @return [Float] the minimum ratio
44
+ def min
45
+ @values.min_by { |v| v['ratio'] }['ratio']
46
+ end
47
+
48
+ # Computes the maximum ration within the time series.
49
+ # @return [Float] the maximum ratio
50
+ def max
51
+ @values.max_by { |v| v['ratio'] }['ratio']
52
+ end
53
+
54
+ # Computes the average ration within the time series.
55
+ # @return [Float] the average ratio
56
+ def avg
57
+ return 0 if count.zero?
58
+ p @values
59
+ @values.inject(0) { |sum, v| sum + v[:ratio] } / @values.count
60
+ end
61
+
62
+ def to_s
63
+ values.to_s
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,40 @@
1
+ module Aeternitas
2
+ module Metrics
3
+ # A TabsTabs resolution represeting 10 minute intervals.
4
+ module TenMinutesResolution
5
+ include TabsTabs::Resolutionable
6
+ extend self
7
+
8
+ PATTERN = '%Y-%m-%d-%H-%M'.freeze
9
+
10
+ def name
11
+ :ten_minutes
12
+ end
13
+
14
+ def serialize(ts)
15
+ Time.utc(ts.year, ts.month, ts.day, ts.hour, (ts.min / 10).to_i).strftime(PATTERN)
16
+ end
17
+
18
+ def deserialize(str)
19
+ dt = DateTime.strptime(str, PATTERN)
20
+ normalize(dt)
21
+ end
22
+
23
+ def from_seconds(s)
24
+ s / 10.minutes
25
+ end
26
+
27
+ def to_seconds
28
+ 10.minutes
29
+ end
30
+
31
+ def add(ts, number)
32
+ ts + number * 10.minutes
33
+ end
34
+
35
+ def normalize(ts)
36
+ Time.utc(ts.year, ts.month, ts.day, ts.hour, ts.min)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,18 @@
1
+ module Aeternitas
2
+ module Metrics
3
+ # Wrapper for {TabsTabs::Metrics::Value::Stats}.
4
+ # It is for Value metrics.
5
+ class Values
6
+ include Enumerable
7
+ extend Forwardable
8
+
9
+ def_delegators :@tabstabs_stats, :sum, :min, :max, :avg, :each, :to_a, :first, :last
10
+
11
+ # Create a new Wrapper
12
+ # @param [TabsTabs::Metrics::Value::Stats] tabstabs_stats the wrapped stats.
13
+ def initialize(tabstabs_stats)
14
+ @tabstabs_stats = tabstabs_stats
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,197 @@
1
+ require 'aeternitas/pollable/configuration'
2
+ require 'aeternitas/pollable/dsl'
3
+
4
+ module Aeternitas
5
+ # Mixin that enables the frequent polling of the receiving class.
6
+ # Classes including this method must implement the .poll method.
7
+ # Polling behaviour can be configured via {.pollable_options}.
8
+ # @note Can only be used by classes inheriting from ActiveRecord::Base
9
+ # @example
10
+ # class MyWebsitePollable < ActiveRecord::Base
11
+ # includes Aeternitas::Pollable
12
+ #
13
+ # polling_options do
14
+ # polling_frequency :daily
15
+ # lock_key ->(obj) {obj.url}
16
+ # end
17
+ #
18
+ # def poll
19
+ # response = HTTParty.get(self.url)
20
+ # raise StandardError, "#{self.url} responded with #{response.status}" unless response.success?
21
+ # HttpSource.create!(content: response.parsed_response)
22
+ # end
23
+ # end
24
+ module Pollable
25
+ extend ActiveSupport::Concern
26
+
27
+ included do
28
+ raise StandardError, 'Aeternitas::Pollable must inherit from ActiveRecord::Base' unless self.ancestors.include?(ActiveRecord::Base)
29
+
30
+ has_one :pollable_meta_data, as: :pollable,
31
+ dependent: :destroy,
32
+ class_name: 'Aeternitas::PollableMetaData'
33
+
34
+ has_many :sources, as: :pollable,
35
+ dependent: :destroy,
36
+ class_name: 'Aeternitas::Source'
37
+
38
+ validates :pollable_meta_data, presence: true
39
+
40
+ before_validation ->(pollable) do
41
+ pollable.pollable_meta_data ||= pollable.build_pollable_meta_data(state: 'waiting')
42
+ pollable.pollable_meta_data.pollable_class = pollable.class.name
43
+ end
44
+
45
+ after_commit ->(pollable) { Aeternitas::Metrics.log(:pollables_created, pollable.class) }, on: :create
46
+
47
+ delegate :next_polling, :last_polling, :disable_polling, to: :pollable_meta_data
48
+ end
49
+
50
+ # This method runs the polling workflow
51
+ def execute_poll
52
+ _before_poll
53
+
54
+ begin
55
+ guard.with_lock { poll }
56
+ rescue StandardError => e
57
+ if pollable_configuration.deactivation_errors.include?(e.class)
58
+ disable_polling(e)
59
+ return false
60
+ elsif pollable_configuration.ignored_errors.include?(e.class)
61
+ pollable_meta_data.has_errored!
62
+ raise Aeternitas::Errors::Ignored, e
63
+ else
64
+ pollable_meta_data.has_errored!
65
+ raise e
66
+ end
67
+ end
68
+
69
+ _after_poll
70
+ rescue StandardError => e
71
+ begin
72
+ log_poll_error(e)
73
+ ensure
74
+ raise e
75
+ end
76
+ end
77
+
78
+
79
+
80
+ # This method implements the class specific polling behaviour.
81
+ # It is only called after the lock was acquired successfully.
82
+ #
83
+ # @abstract This method must be implemented when {Aeternitas::Pollable} is included
84
+ def poll
85
+ raise NotImplementedError, "#{self.class.name} does not implement #poll, required by Aeternitas::Pollable"
86
+ end
87
+
88
+ # Registers the instance as pollable.
89
+ #
90
+ # @note Manual registration is only needed if the object was created before
91
+ # {Aeternitas::Pollable} was included. Otherwise it is done automatically after creation.
92
+ def register_pollable
93
+ self.pollable_meta_data ||= create_pollable_meta_data(
94
+ state: 'waiting',
95
+ pollable_class: self.class.name
96
+ )
97
+ end
98
+
99
+ def guard
100
+ guard_key = pollable_configuration.guard_options[:key].call(self)
101
+ guard_timeout = pollable_configuration.guard_options[:timeout]
102
+ guard_cooldown = pollable_configuration.guard_options[:cooldown]
103
+ Aeternitas::Guard.new(guard_key, guard_cooldown, guard_timeout)
104
+ end
105
+
106
+ # Access the Pollables configuration
107
+ #
108
+ # @return [Aeternitas::Pollable::Configuration] the pollables configuration
109
+ def pollable_configuration
110
+ self.class.pollable_configuration
111
+ end
112
+
113
+ # Creates a new source with the given content if it does not exist
114
+ # @example
115
+ # #...
116
+ # def poll
117
+ # response = HTTParty.get("http://example.com")
118
+ # add_source(response.parsed_response)
119
+ # end
120
+ # #...
121
+ # @param [String] raw_content the sources raw content
122
+ # @return [Aeternitas::Source] the newly created or existing source
123
+ def add_source(raw_content)
124
+ source = self.sources.create(raw_content: raw_content)
125
+ return nil unless source.persisted?
126
+
127
+ Aeternitas::Metrics.log(:sources_created, self.class)
128
+ source
129
+ end
130
+
131
+ private
132
+
133
+ # Run all prepolling methods
134
+ def _before_poll
135
+ @start_time = Time.now
136
+ Aeternitas::Metrics.log(:polls, self.class)
137
+
138
+ pollable_configuration.before_polling.each { |action| action.call(self) }
139
+ pollable_meta_data.poll!
140
+ end
141
+
142
+ # Run all postpolling methods
143
+ def _after_poll
144
+ pollable_meta_data.wait! do
145
+ pollable_meta_data.update_attributes!(
146
+ last_polling: Time.now,
147
+ next_polling: pollable_configuration.polling_frequency.call(self)
148
+ )
149
+ end
150
+
151
+ pollable_configuration.after_polling.each { |action| action.call(self) }
152
+
153
+ if @start_time
154
+ execution_time = Time.now - @start_time
155
+ Aeternitas::Metrics.log_value(:execution_time, self.class, execution_time)
156
+ Aeternitas::Metrics.log(:guard_timeout_exceeded, self.class) if execution_time > pollable_configuration.guard_options[:timeout]
157
+ @start_time = nil
158
+ end
159
+ Aeternitas::Metrics.log(:successful_polls, self.class)
160
+ end
161
+
162
+ def log_poll_error(e)
163
+ if e.is_a? Aeternitas::Guard::GuardIsLocked
164
+ Aeternitas::Metrics.log(:guard_locked, self.class)
165
+ Aeternitas::Metrics.log_value(:guard_timeout, self.class, e.timeout - Time.now)
166
+ elsif e.is_a? Aeternitas::Errors::Ignored
167
+ Aeternitas::Metrics.log(:ignored_error, self.class)
168
+ Aeternitas::Metrics.log(:failed_polls, self.class)
169
+ else
170
+ Aeternitas::Metrics.log(:failed_polls, self.class)
171
+ end
172
+ end
173
+
174
+ class_methods do
175
+ # Access the Pollables configuration
176
+ # @return [Aeternitas::Pollable::Configuration] the pollables configuration
177
+ def pollable_configuration
178
+ @pollable_configuration ||= Aeternitas::Pollable::Configuration.new
179
+ end
180
+
181
+ def pollable_configuration=(config)
182
+ @pollable_configuration = config
183
+ end
184
+
185
+ # Configure the polling process.
186
+ # For available configuration options see {Aeternitas::Pollable::Configuration} and {Aeternitas::Pollable::DSL}
187
+ def polling_options(&block)
188
+ Aeternitas::Pollable::Dsl.new(self.pollable_configuration, &block)
189
+ end
190
+
191
+ def inherited(other)
192
+ super
193
+ other.pollable_configuration = @pollable_configuration.copy
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,64 @@
1
+ module Aeternitas
2
+ module Pollable
3
+ # Holds the configuration of a pollable class
4
+ # @!attribute [rw] polling_frequency
5
+ # Method that calculates the next polling time after a successful poll.
6
+ # (Default: {Aeternitas::PollingFrequency::DAILY})
7
+ # @!attribute [rw] before_polling
8
+ # Methods to be run before each poll
9
+ # @!attribute [rw] after_polling
10
+ # Methods to be run after each successful poll
11
+ # @!attribute [rw] queue
12
+ # Sidekiq queue the poll job will be enqueued in (Default: 'polling')
13
+ # @!attribute [rw] guard_options
14
+ # Configuration of the pollables lock (Default: key => class+id, cooldown => 5.seconds, timeout => 10.minutes)
15
+ # @!attribute [rw] deactivation_errors
16
+ # The pollable instance will be deactivated if any of these errors occur while polling
17
+ # @!attribute [rw] ignored_errors
18
+ # Errors in this list will be wrapped by {Aeternitas::Error::Ignored} if they occur while polling
19
+ # (i.e. ignore in your exception tracker)
20
+ # @!attribute [rw] sleep_on_guard_locked
21
+ # When set to true poll jobs (and effectively the Sidekiq worker thread) will sleep until the
22
+ # lock is released if the lock could not be acquired. (Default: true)
23
+ class Configuration
24
+ attr_accessor :deactivation_errors,
25
+ :before_polling,
26
+ :queue,
27
+ :polling_frequency,
28
+ :after_polling,
29
+ :guard_options,
30
+ :ignored_errors,
31
+ :sleep_on_guard_locked
32
+
33
+ # Creates a new Configuration with default options
34
+ def initialize
35
+ @polling_frequency = Aeternitas::PollingFrequency::DAILY
36
+ @before_polling = []
37
+ @after_polling = []
38
+ @guard_options = {
39
+ key: ->(obj) { return obj.class.name.to_s },
40
+ timeout: 10.minutes,
41
+ cooldown: 5.seconds
42
+ }
43
+ @deactivation_errors = []
44
+ @ignored_errors = []
45
+ @queue = 'polling'
46
+ @sleep_on_guard_locked = true
47
+ end
48
+
49
+ def copy
50
+ config = Configuration.new
51
+ config.polling_frequency = self.polling_frequency
52
+ config.before_polling = self.before_polling.deep_dup
53
+ config.after_polling = self.after_polling.deep_dup
54
+ config.guard_options = self.guard_options.deep_dup
55
+ config.deactivation_errors = self.deactivation_errors.deep_dup
56
+ config.ignored_errors = self.ignored_errors.deep_dup
57
+ config.queue = self.queue
58
+ config.sleep_on_guard_locked = self.sleep_on_guard_locked
59
+ config
60
+ end
61
+ end
62
+ end
63
+ end
64
+
@@ -0,0 +1,124 @@
1
+ module Aeternitas
2
+ module Pollable
3
+ # DSL wrapper to conveniently configure pollables
4
+ class Dsl
5
+ # Create a new DSL instance and configure the configuration with the given block
6
+ # @param [Aeternitas::Pollable::Configuration] configuration a pollables configuration
7
+ # @param [Proc] block configuration block
8
+ def initialize(configuration, &block)
9
+ @configuration = configuration
10
+ instance_eval(&block)
11
+ end
12
+
13
+ # Configures the polling frequency. This can be either the name of a {Aeternitas::PollingFrequency}
14
+ # or a lambda that receives a pollable instance and returns a DateTime
15
+ #
16
+ # @param [Symbol, Proc] frequency Sets the polling frequency.
17
+ # representing the next polling time.
18
+ # @example using a preset
19
+ # polling_frequency :weekly
20
+ # @example using a custom block
21
+ # polling_frequency ->(pollable) {Time.now + 1.month + Time.now - pollable.created_at.to_i / 3.month * 1.month}
22
+ # @todo allow custom methods via reference
23
+ def polling_frequency(frequency)
24
+ if frequency.is_a?(Symbol)
25
+ @configuration.polling_frequency = Aeternitas::PollingFrequency.by_name(frequency)
26
+ else
27
+ @configuration.polling_frequency = frequency
28
+ end
29
+ end
30
+
31
+ # Configures a method that will be run before every poll
32
+ #
33
+ # @param [Symbol, Block] method method or method name
34
+ # @example method by reference
35
+ # before_polling :my_method
36
+ # ...
37
+ # def my_method(pollable) do_something end
38
+ # @example method by block
39
+ # before_polling ->(pollable) {do_something}
40
+ def before_polling(method)
41
+ if method.is_a?(Symbol)
42
+ @configuration.before_polling << ->(pollable) { pollable.send(method) }
43
+ else
44
+ @configuration.before_polling << method
45
+ end
46
+ end
47
+
48
+ # Configures a method that will be run after every successful poll
49
+ #
50
+ # @param [Symbol, Block] method method or method name
51
+ # @example method by reference
52
+ # after:polling :my_method
53
+ # ...
54
+ # def my_method(pollable) do_something end
55
+ # @example method by block
56
+ # after_polling ->(pollable) {do_something}
57
+ def after_polling(method)
58
+ if method.is_a?(Symbol)
59
+ @configuration.after_polling << ->(pollable) { pollable.send(method) }
60
+ else
61
+ @configuration.after_polling << method
62
+ end
63
+ end
64
+
65
+ # Configure errors that will cause the pollable instance to be deactivated immediately during poll.
66
+ #
67
+ # @param [Object] error_class error classes
68
+ def deactivate_on(*error_class)
69
+ @configuration.deactivation_errors |= error_class
70
+ end
71
+
72
+ # Configure errors that will be wrapped in {Aeternitas::Error::Ignored}.
73
+ # Use this to group exceptions which should be ignored in your exception tracker.
74
+ #
75
+ # @param [Object] error_class error classes
76
+ def ignore_error(*error_class)
77
+ @configuration.ignored_errors |= error_class
78
+ end
79
+
80
+ # Configure the Sidekiq queue into which the instance's poll jobs will be enqueued.
81
+ # @param [String] queue name of the Sidekiq queue
82
+ def queue(queue)
83
+ @configuration.queue = queue
84
+ end
85
+
86
+ # Configure the guard key. This can be either a fixed String, a method reference or a block
87
+ #
88
+ # @param [String, Symbol, Proc] key lock key
89
+ # @example using a fixed String
90
+ # guard_key "MyLockKey"
91
+ # @example using a method reference
92
+ # guard_key :url
93
+ # @example using a block
94
+ # guard_key ->(pollable) {URI.parse(pollable.url).host}
95
+ def guard_key(key)
96
+ @configuration.guard_options[:key] = case key
97
+ when Symbol
98
+ ->(obj) { return obj.send(key) }
99
+ when Proc
100
+ key
101
+ else
102
+ ->(obj) { return key.to_s }
103
+ end
104
+ end
105
+
106
+ # Configure the guard.
107
+ # @see guard_key
108
+ # @see Aeternitas::Guard
109
+ # @param [Hash] options guard options
110
+ def guard_options(options)
111
+ @configuration.guard_options.merge!(options)
112
+ end
113
+
114
+ # Configure the behaviour of poll jobs if a lock can't be acquired.
115
+ # When set to true poll jobs (and effectively the Sidekiq worker thread) will sleep until the
116
+ # lock is released if the lock could not be acquired.
117
+ # @param [Boolean] switch true|false
118
+ def sleep_on_guard_locked(switch)
119
+ @configuration.sleep_on_guard_locked = switch
120
+ end
121
+ end
122
+ end
123
+ end
124
+