aeternitas 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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