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,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "aeternitas"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,80 @@
1
+ require "active_support/all"
2
+ require "redis"
3
+ require "connection_pool"
4
+ require "sidekiq-unique-jobs"
5
+ require "tabs_tabs"
6
+ require "aeternitas/version"
7
+ require "aeternitas/guard"
8
+ require "aeternitas/pollable"
9
+ require "aeternitas/pollable_meta_data"
10
+ require "aeternitas/source"
11
+ require "aeternitas/polling_frequency"
12
+ require "aeternitas/errors"
13
+ require "aeternitas/storage_adapter"
14
+ require "aeternitas/sidekiq"
15
+ require "aeternitas/metrics"
16
+
17
+ # Aeternitas
18
+ module Aeternitas
19
+
20
+ # Get the configured redis connection
21
+ # @return [ConnectionPool::Wrapper] returns a redis connection from the pool
22
+ def self.redis
23
+ @redis ||= ConnectionPool::Wrapper.new(size: 5, timeout: 3) { Redis.new(self.config.redis) }
24
+ end
25
+
26
+ # Access the configuration
27
+ # @return [Aeternitas::Configuration] the Aeternitas configuration
28
+ def self.config
29
+ @config ||= Configuration.new
30
+ end
31
+
32
+ # Configure Aeternitas
33
+ # @see Aeternitas::Configuration
34
+ # @yieldparam [Aeternitas::Configuration] config the aeternitas configuration
35
+ def self.configure
36
+ yield(self.config)
37
+ end
38
+
39
+ # Enqueues all active pollables for which next polling is lower than the current time
40
+ def self.enqueue_due_pollables
41
+ Aeternitas::PollableMetaData.due.find_each do |pollable_meta_data|
42
+ Aeternitas::Sidekiq::PollJob
43
+ .set(queue: pollable_meta_data.pollable.pollable_configuration.queue)
44
+ .perform_async(pollable_meta_data.id)
45
+ pollable_meta_data.enqueue
46
+ pollable_meta_data.save
47
+ end
48
+ end
49
+
50
+ # Stores the global Aeternitas configuration
51
+ # @!attribute [rw] redis
52
+ # Redis configuration hash, Default: nil
53
+ # @!attribute [rw] storage_adapter_config
54
+ # Storage adapter configuration, See {Aeternitas::StorageAdapter} for configuration options
55
+ # @!attribute [rw] storage_adapter
56
+ # Storage adapter class. Default: {Aeternitas::StorageAdapter::File}
57
+ class Configuration
58
+ attr_accessor :redis, :storage_adapter, :storage_adapter_config
59
+
60
+ def initialize
61
+ @redis = nil
62
+ @storage_adapter = Aeternitas::StorageAdapter::File
63
+ @storage_adapter_config = {
64
+ directory: defined?(Rails) ? File.join(Rails.root, %w[aeternitas_data]) : File.join(Dir.getwd, 'aeternitas_data')
65
+ }
66
+ end
67
+
68
+ # Creates a new StorageAdapter instance with the given options
69
+ # @return [Aeternitas::StoragesAdapter] new storage adapter instance
70
+ def get_storage_adapter
71
+ @storage_adapter.new(storage_adapter_config)
72
+ end
73
+
74
+ def redis=(redis_config)
75
+ @redis = redis_config
76
+ TabsTabs.configure { |tabstabs_config| tabstabs_config.redis = redis_config }
77
+ end
78
+ end
79
+ end
80
+
@@ -0,0 +1,48 @@
1
+ module Aeternitas
2
+ module Errors
3
+ # Wrapper for ignored errors.
4
+ # This can be used to conveniently exclude certain errors from e.g. error trackers
5
+ # @!attribute [r] original error
6
+ # the wrapped error
7
+ class Ignored < StandardError
8
+ attr_reader :original_error
9
+
10
+ # Create a new Instance.
11
+ #
12
+ # @param [StandardError] original_error the wrapped error
13
+ def initialize(original_error)
14
+ @original_error = original_error
15
+ super("#{original_error.class} - #{original_error.message}")
16
+ end
17
+
18
+ end
19
+
20
+ # Raised when a source data already exists.
21
+ # @!attribute [r] fingerprint
22
+ # the sources fingerprint
23
+ class SourceDataExists < StandardError
24
+ attr_reader :fingerprint
25
+
26
+ # Create a new Exception
27
+ # @param [String] fingerprint the sources fingerprint
28
+ def initialize(fingerprint)
29
+ @fingerprint = fingerprint
30
+ super("The source entry with fingerprint '#{fingerprint}' already exists!")
31
+ end
32
+ end
33
+
34
+ # Raised when a source entry does not exist.
35
+ # @!attribute [r] fingerprint
36
+ # the sources fingerprint
37
+ class SourceDataNotFound < StandardError
38
+ attr_reader :fingerprint
39
+
40
+ # Create a new Exception
41
+ # @param [String] fingerprint the sources fingerprint
42
+ def initialize(fingerprint)
43
+ @fingerprint = fingerprint
44
+ super("The source entry with fingerprint '#{fingerprint}' does not exist!")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,199 @@
1
+ require 'active_support/duration'
2
+ require 'securerandom'
3
+
4
+ module Aeternitas
5
+ # A distributed lock that can not be acquired after being unlocked for a certain time (cooldown period).
6
+ # Using Redis key expiration we ensure locks are released even after workers crash after a configurable timout period.
7
+ #
8
+ # @example
9
+ # guard = Aeternitas::Guard.new("Twitter-MY_API_KEY", 5.seconds)
10
+ # begin
11
+ # guard.with_lock do
12
+ # twitter_client.user_timeline('Darth_Max')
13
+ # end
14
+ # rescue Twitter::TooManyRequests => e
15
+ # guard.sleep_until(e.rate_limit.reset_at)
16
+ # raise Aeternitas::Guard::GuardIsLocked(e.rate_limit.reset_at)
17
+ # end
18
+ #
19
+ # @!attribute [r] id
20
+ # @return [String] the guards id
21
+ # @!attribute [r] timeout
22
+ # @return [ActiveSupport::Duration] the locks timeout duration
23
+ # @!attribute [r] cooldown
24
+ # @return [ActiveSupport::Duration] cooldown time, in which the lock can't be acquired after being released
25
+ # @!attribute [r] token
26
+ # @return [String] cryptographic token which ensures we do not lock/unlock a guard held by another process
27
+ class Guard
28
+
29
+ attr_reader :id, :timeout, :cooldown, :token
30
+
31
+ # Create a new Guard
32
+ #
33
+ # @param [String] id Lock id
34
+ # @param [ActiveRecord::Duration] cooldown Cooldown time
35
+ # @param [ActiveRecord::Duration] timeout Lock timeout
36
+ # @return [Aeternitas::Guard] Creates a new Instance
37
+ def initialize(id, cooldown, timeout = 10.minutes)
38
+ @id = id
39
+ @cooldown = cooldown
40
+ @timeout = timeout
41
+ @token = SecureRandom.hex(10)
42
+ end
43
+
44
+ # Runs a given block if the lock can be acquired and releases the lock afterwards.
45
+ #
46
+ # @raise [Aeternitas::LockWithCooldown::GuardIsLocked] if the lock can not be acquired
47
+ # @example
48
+ # Guard.new("MyId", 5.seconds, 10.minutes).with_lock { do_request() }
49
+ def with_lock
50
+ acquire_lock!
51
+ begin
52
+ yield
53
+ ensure
54
+ unlock
55
+ end
56
+ end
57
+
58
+ # Locks the guard until the given time.
59
+ #
60
+ # @param [Time] until_time sleep time
61
+ # @param [String] msg hint why the guard sleeps
62
+ def sleep_until(until_time, msg = nil)
63
+ sleep(until_time, msg)
64
+ end
65
+
66
+ # Locks the guard for the given duration.
67
+ #
68
+ # @param [ActiveSupport::Duration] duration sleeping duration
69
+ # @param [String] msg hint why the guard sleeps
70
+ def sleep_for(duration, msg = nil)
71
+ raise ArgumentError, 'duration must be an ActiveRecord::Duration' unless duration.is_a?(ActiveSupport::Duration)
72
+ sleep_until(duration.from_now, msg)
73
+ end
74
+
75
+ private
76
+
77
+ # Tries to acquire the lock.
78
+ #
79
+ # @example The Redis value looks like this
80
+ # {
81
+ # id: 'MyId'
82
+ # state: 'processing'
83
+ # timeout: '3600'
84
+ # cooldown: '5'
85
+ # locked_until: '2017-01-01 10:10:00'
86
+ # token: '1234567890'
87
+ # }
88
+ # @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired
89
+ def acquire_lock!
90
+ payload = {
91
+ 'id' => @id,
92
+ 'state' => 'processing',
93
+ 'timeout' => @timeout,
94
+ 'cooldown' => @cooldown,
95
+ 'locked_until' => @timeout.from_now,
96
+ 'token' => @token
97
+ }
98
+
99
+ has_lock = Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @timeout.to_i, nx: true)
100
+
101
+ raise(GuardIsLocked.new(@id, get_timeout)) unless has_lock
102
+ end
103
+
104
+ # Tries to unlock the guard. This starts the cooldown phase.
105
+ #
106
+ # @example The Redis value looks like this
107
+ # {
108
+ # id: 'MyId'
109
+ # state: 'cooldown'
110
+ # timeout: '3600'
111
+ # cooldown: '5'
112
+ # locked_until: '2017-01-01 10:00:05'
113
+ # token: '1234567890'
114
+ # }
115
+ def unlock
116
+ return false unless holds_lock?
117
+
118
+ payload = {
119
+ 'id' => @id,
120
+ 'state' => 'cooldown',
121
+ 'timeout' => @timeout,
122
+ 'cooldown' => @cooldown,
123
+ 'locked_until' => @cooldown.from_now,
124
+ 'token' => @token
125
+ }
126
+
127
+ Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @cooldown.to_i)
128
+ end
129
+
130
+ # Lets the guard sleep until the given date.
131
+ # This means that non can acquire the guards lock
132
+ #
133
+ # @example The Redis value looks like this
134
+ # {
135
+ # id: 'MyId'
136
+ # state: 'sleeping'
137
+ # timeout: '3600'
138
+ # cooldown: '5'
139
+ # locked_until: '2017-01-01 13:00'
140
+ # message: "API Quota Reached"
141
+ # }
142
+ # @todo Should this raise an error if the lock is not owned by this instance?
143
+ # @param [Time] sleep_timeout for how long will the guard sleep
144
+ # @param [String] msg hint why the guard sleeps
145
+ def sleep(sleep_timeout, msg = nil)
146
+ payload = {
147
+ 'id' => @id,
148
+ 'state' => 'sleeping',
149
+ 'timeout' => @timeout,
150
+ 'cooldown' => @cooldown,
151
+ 'locked_until' => sleep_timeout
152
+ }
153
+ payload.merge(message: msg) if msg
154
+
155
+ Aeternitas.redis.set(@id, JSON.unparse(payload), ex: (sleep_timeout - Time.now).seconds.to_i)
156
+ end
157
+
158
+ # Checks if this instance holds the lock. This is done by retrieving the value from redis and
159
+ # comparing the token value. If they match, than the lock is held by this instance.
160
+ #
161
+ # @todo Make the check atomic
162
+ # @return [Boolean] if the lock is held by this instance
163
+ def holds_lock?
164
+ payload = get_payload
165
+ payload['token'] == @token && payload['state'] == 'processing'
166
+ end
167
+
168
+ # Returns the guards current timeout.
169
+ #
170
+ # @return [Time] the guards current timeout
171
+ def get_timeout
172
+ payload = get_payload
173
+ payload['state'] == 'processing' ? payload['cooldown'].to_i.seconds.from_now : Time.parse(payload['locked_until'])
174
+ end
175
+
176
+ # Retrieves the locks payload from redis.
177
+ #
178
+ # @return [Hash] the locks payload
179
+ def get_payload
180
+ value = Aeternitas.redis.get(@id)
181
+ return {} unless value
182
+ JSON.parse(value)
183
+ end
184
+
185
+ # Custom error class thrown when the lock can not be acquired
186
+ # @!attribute [r] timeout
187
+ # @return [DateTime] the locks current timeout
188
+ class GuardIsLocked < StandardError
189
+ attr_reader :timeout
190
+
191
+ def initialize(resource_id, timeout, reason = nil)
192
+ msg = "Resource '#{resource_id}' is locked until #{timeout}."
193
+ msg += " Reason: #{reason}" if reason
194
+ super(msg)
195
+ @timeout = timeout
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,162 @@
1
+ require 'aeternitas/metrics/ten_minutes_resolution'
2
+ require 'aeternitas/metrics/counter'
3
+ require 'aeternitas/metrics/values'
4
+ require 'aeternitas/metrics/ratio'
5
+
6
+ module Aeternitas
7
+ # Provides extensive metrics for Aeternitas.
8
+ # Every metric is scoped by pollable class.
9
+ # Available metrics are:
10
+ # - polls => Number of polling runs
11
+ # - successful_polls => Number of successful polling runs
12
+ # - failed_polls => Number of failed polling runs (includes IgnoredErrors,
13
+ # excludes deactivation errors and Lock errors)
14
+ # - ignored_errors => Number of raised {Aeternitas::Errors::Ignored}
15
+ # - deactivation_errors => Number of errors raised which are declared as deactivation_errors
16
+ # - execution_time => Job execution time in seconds
17
+ # - guard_locked => Number of encountered locked guards
18
+ # - guard_timeout => Time until the guard is unlocked in seconds
19
+ # - guard_timeout_exceeded => Number of jobs that ran longer than the guards timeout
20
+ # - pollables_created => Number of created pollables
21
+ # - sources_created => Number of created sources
22
+ #
23
+ # Available Resolutions are:
24
+ # - :minute (stored for 3 days)
25
+ # - :ten_minutes (stored for 14 days)
26
+ # - :hour (stored for 2 months)
27
+ # - :day (stored indefinitely)
28
+ #
29
+ # Every metric can be accessed via a getter method:
30
+ # @example
31
+ # Aeternitas::Metrics.polls MyPollable, from: 3.days.ago, to: Time.now, resolution: :hour
32
+ # Aeternitas::Metrics.execution_times MyPollable
33
+ # @see #get
34
+ module Metrics
35
+ AVAILABLE_METRICS = {
36
+ polls: :counter,
37
+ successful_polls: :counter,
38
+ failed_polls: :counter,
39
+ ignored_errors: :counter,
40
+ deactivations: :counter,
41
+ execution_time: :value,
42
+ guard_locked: :counter,
43
+ guard_timeout: :value,
44
+ guard_timeout_exceeded: :counter,
45
+ sources_created: :counter,
46
+ pollables_created: :counter
47
+ }.freeze
48
+
49
+ TabsTabs.configure do |tabstabs_config|
50
+ tabstabs_config.unregister_resolutions(:week, :month, :year)
51
+
52
+ tabstabs_config.register_resolution Aeternitas::Metrics::TenMinutesResolution
53
+
54
+ tabstabs_config.set_expirations(
55
+ minute: 3.days,
56
+ ten_minutes: 14.days,
57
+ hour: 2.months
58
+ )
59
+ end
60
+
61
+ AVAILABLE_METRICS.each_pair do |metric, _|
62
+ module_eval <<-eoruby, __FILE__, __LINE__ + 1
63
+ def self.#{metric}(pollable, from: 1.hour.ago, to: Time.now, resolution: :minute)
64
+ self.get(:#{metric}, pollable, from: from, to: to, resolution: resolution )
65
+ end
66
+ eoruby
67
+ end
68
+
69
+ # Increses the specified counter metric for the given pollable.
70
+ # @param [Symbol, String] name the metric
71
+ # @param [Pollable] pollable_class pollable instance
72
+ def self.log(name, pollable_class)
73
+ raise('Metric not found') unless AVAILABLE_METRICS.key? name
74
+ raise ArgumentError, "#{name} isn't a Counter" unless AVAILABLE_METRICS[name] == :counter
75
+ begin
76
+ TabsTabs.increment_counter(get_key(name, pollable_class))
77
+ TabsTabs.increment_counter(get_key(name, Aeternitas::Pollable))
78
+ rescue StandardError ; end
79
+ end
80
+
81
+ # Logs a value in a value metric for the given pollable.
82
+ # @param [Symbol String] name the metric
83
+ # @param [Pollable] pollable_class pollable instance
84
+ # @param [Object] value the value
85
+ def self.log_value(name, pollable_class, value)
86
+ raise('Metric not found') unless AVAILABLE_METRICS.key? name
87
+ raise(ArgumentError, "#{name} isn't a Value") unless AVAILABLE_METRICS[name] == :value
88
+ begin
89
+ TabsTabs.record_value(get_key(name, pollable_class), value)
90
+ TabsTabs.record_value(get_key(name, Aeternitas::Pollable), value)
91
+ rescue StandardError ; end
92
+ end
93
+
94
+ # Retrieves the stats of the given metric in the given time frame and resolution.
95
+ # @param [Symbol String] name the metric
96
+ # @param [Object] pollable_class the pollable class
97
+ # @param [DateTime] from begin of the time frame
98
+ # @param [DateTime] to end of the timeframe
99
+ # @param [Symbol] resolution resolution
100
+ # @return [Aeternitas::Metrics::Counter, Aeternitas::Metrics::Value] stats
101
+ def self.get(name, pollable_class, from: 1.hour.ago, to: Time.now, resolution: :minute)
102
+ raise('Metric not found') unless AVAILABLE_METRICS.key? name
103
+ raise('Invalid interval') if from > to
104
+ result = TabsTabs.get_stats(get_key(name, pollable_class), from..to, resolution)
105
+ if AVAILABLE_METRICS[name] == :counter
106
+ Counter.new(result)
107
+ else
108
+ Values.new(result)
109
+ end
110
+ rescue TabsTabs::UnknownMetricError => _
111
+ TabsTabs.create_metric(get_key(name, pollable_class), AVAILABLE_METRICS[name].to_s)
112
+ get(name, pollable_class, from: from, to: to, resolution: resolution)
113
+ end
114
+
115
+ # Returns the failure ratio of the given job for given time frame and resolution
116
+ # @param [Symbol String] name the metric
117
+ # @param [Object] pollable_class the pollable class
118
+ # @param [DateTime] from begin of the time frame
119
+ # @param [DateTime] to end of the timeframe
120
+ # @param [Symbol] resolution resolution
121
+ # @return [Aeternitas::Metrics::Ratio] ratio time series
122
+ def self.failure_ratio(pollable_class, from: 1.hour.ago, to: Time.now, resolution: :minute)
123
+ polls = polls(pollable_class, from: from, to: to, resolution: resolution)
124
+ failed_polls = failed_polls(pollable_class, from: from, to: to, resolution: resolution)
125
+ Ratio.new(from, to, resolution, calculate_ratio(polls, failed_polls))
126
+ end
127
+
128
+ # Returns the lock ratio of the given job for given time frame and resolution
129
+ # @param [Symbol String] name the metric
130
+ # @param [Object] pollable_class the pollable class
131
+ # @param [DateTime] from begin of the time frame
132
+ # @param [DateTime] to end of the timeframe
133
+ # @param [Symbol] resolution resolution
134
+ # @return [Aeternitas::Metrics::Ratio] ratio time series
135
+ def self.guard_locked_ratio(pollable_class, from: 1.hour.ago, to: Time.now, resolution: :minute)
136
+ polls = polls(pollable_class, from: from, to: to, resolution: resolution)
137
+ guard_locked = guard_locked(pollable_class, from: from, to: to, resolution: resolution)
138
+ Ratio.new(from, to, resolution, calculate_ratio(polls, guard_locked))
139
+ end
140
+
141
+ # Computes the metric key of a given metric-pollable pair
142
+ # @param [Symbol, String] name the metric
143
+ # @param [Object] pollable_class pollable class
144
+ # @return [String] the metric key
145
+ def self.get_key(name, pollable_class)
146
+ "#{name}:#{pollable_class.name}"
147
+ end
148
+
149
+ # Computes the ratio of a base counter time series and a target counter time series
150
+ # @param [Array] base base time series data
151
+ # @param [Array] target target time series data
152
+ # @return [Array] ratio time series data
153
+ def self.calculate_ratio(base, target)
154
+ base.zip(target).map do |b, t|
155
+ {
156
+ timestamp: b['timestamp'],
157
+ ratio: b['count'].to_i.zero? ? 0 : t['count'].to_i / b['count'].to_f
158
+ }.with_indifferent_access
159
+ end
160
+ end
161
+ end
162
+ end