aeternitas 0.2.0 → 2.0.0.rc1
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.
- checksums.yaml +4 -4
- data/.gitattributes +1 -0
- data/.github/workflows/lint.yml +25 -0
- data/.github/workflows/tests.yml +28 -0
- data/.gitignore +2 -5
- data/.ruby-version +1 -1
- data/CHANGELOG.md +35 -0
- data/Gemfile +1 -1
- data/README.md +93 -148
- data/Rakefile +1 -1
- data/aeternitas.gemspec +23 -34
- data/lib/aeternitas/aeternitas_job.rb +7 -0
- data/lib/aeternitas/cleanup_old_metrics_job.rb +12 -0
- data/lib/aeternitas/cleanup_stale_locks_job.rb +12 -0
- data/lib/aeternitas/errors.rb +1 -2
- data/lib/aeternitas/guard.rb +72 -106
- data/lib/aeternitas/guard_lock.rb +19 -0
- data/lib/aeternitas/maintenance.rb +35 -0
- data/lib/aeternitas/metric.rb +12 -0
- data/lib/aeternitas/metrics.rb +54 -136
- data/lib/aeternitas/poll_job.rb +135 -0
- data/lib/aeternitas/pollable/configuration.rb +21 -22
- data/lib/aeternitas/pollable/dsl.rb +16 -17
- data/lib/aeternitas/pollable.rb +19 -18
- data/lib/aeternitas/pollable_meta_data.rb +18 -9
- data/lib/aeternitas/polling_frequency.rb +4 -4
- data/lib/aeternitas/source.rb +5 -5
- data/lib/aeternitas/storage_adapter/file.rb +9 -12
- data/lib/aeternitas/storage_adapter.rb +1 -3
- data/lib/aeternitas/unique_job_lock.rb +15 -0
- data/lib/aeternitas/version.rb +1 -1
- data/lib/aeternitas.rb +23 -26
- data/lib/generators/aeternitas/install_generator.rb +14 -8
- data/lib/generators/aeternitas/templates/add_aeternitas.rb.erb +34 -2
- data/lib/generators/aeternitas/templates/initializer.rb +10 -7
- metadata +35 -123
- data/.idea/.rakeTasks +0 -7
- data/.idea/misc.xml +0 -4
- data/.idea/modules.xml +0 -8
- data/.idea/vcs.xml +0 -6
- data/.rspec +0 -2
- data/.rubocop.yml +0 -2
- data/.travis.yml +0 -8
- data/lib/aeternitas/metrics/counter.rb +0 -18
- data/lib/aeternitas/metrics/ratio.rb +0 -67
- data/lib/aeternitas/metrics/ten_minutes_resolution.rb +0 -40
- data/lib/aeternitas/metrics/values.rb +0 -18
- data/lib/aeternitas/sidekiq/middleware.rb +0 -31
- data/lib/aeternitas/sidekiq/poll_job.rb +0 -30
- data/lib/aeternitas/sidekiq.rb +0 -5
- data/logo.png +0 -0
- data/logo.svg +0 -198
data/lib/aeternitas/guard.rb
CHANGED
@@ -1,9 +1,9 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require "active_support/duration"
|
2
|
+
require "securerandom"
|
3
3
|
|
4
4
|
module Aeternitas
|
5
5
|
# A distributed lock that can not be acquired after being unlocked for a certain time (cooldown period).
|
6
|
-
# Using
|
6
|
+
# Using a database table (`aeternitas_guard_locks`) with pessimistic locking we ensure atomicity and prevent race conditions.
|
7
7
|
#
|
8
8
|
# @example
|
9
9
|
# guard = Aeternitas::Guard.new("Twitter-MY_API_KEY", 5.seconds)
|
@@ -13,7 +13,7 @@ module Aeternitas
|
|
13
13
|
# end
|
14
14
|
# rescue Twitter::TooManyRequests => e
|
15
15
|
# guard.sleep_until(e.rate_limit.reset_at)
|
16
|
-
# raise Aeternitas::Guard::GuardIsLocked(e.rate_limit.reset_at)
|
16
|
+
# raise Aeternitas::Guard::GuardIsLocked.new(e.rate_limit.reset_at)
|
17
17
|
# end
|
18
18
|
#
|
19
19
|
# @!attribute [r] id
|
@@ -25,7 +25,6 @@ module Aeternitas
|
|
25
25
|
# @!attribute [r] token
|
26
26
|
# @return [String] cryptographic token which ensures we do not lock/unlock a guard held by another process
|
27
27
|
class Guard
|
28
|
-
|
29
28
|
attr_reader :id, :timeout, :cooldown, :token
|
30
29
|
|
31
30
|
# Create a new Guard
|
@@ -35,15 +34,15 @@ module Aeternitas
|
|
35
34
|
# @param [ActiveRecord::Duration] timeout Lock timeout
|
36
35
|
# @return [Aeternitas::Guard] Creates a new Instance
|
37
36
|
def initialize(id, cooldown, timeout = 10.minutes)
|
38
|
-
@id
|
37
|
+
@id = id
|
39
38
|
@cooldown = cooldown
|
40
|
-
@timeout
|
41
|
-
@token
|
39
|
+
@timeout = timeout
|
40
|
+
@token = SecureRandom.hex(10)
|
42
41
|
end
|
43
42
|
|
44
43
|
# Runs a given block if the lock can be acquired and releases the lock afterwards.
|
45
44
|
#
|
46
|
-
# @raise [Aeternitas::
|
45
|
+
# @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired
|
47
46
|
# @example
|
48
47
|
# Guard.new("MyId", 5.seconds, 10.minutes).with_lock { do_request() }
|
49
48
|
def with_lock
|
@@ -55,6 +54,22 @@ module Aeternitas
|
|
55
54
|
end
|
56
55
|
end
|
57
56
|
|
57
|
+
# Tries to unlock the guard and starts the cooldown phase.
|
58
|
+
# It only releases the lock if the token matches and the state is 'processing'.
|
59
|
+
def unlock
|
60
|
+
Aeternitas::GuardLock.transaction do
|
61
|
+
lock = Aeternitas::GuardLock.where(lock_key: @id, token: @token).lock.first
|
62
|
+
return false unless lock&.processing?
|
63
|
+
|
64
|
+
lock.update!(
|
65
|
+
state: :cooldown,
|
66
|
+
locked_until: @cooldown.from_now,
|
67
|
+
reason: nil
|
68
|
+
)
|
69
|
+
end
|
70
|
+
true
|
71
|
+
end
|
72
|
+
|
58
73
|
# Locks the guard until the given time.
|
59
74
|
#
|
60
75
|
# @param [Time] until_time sleep time
|
@@ -68,118 +83,69 @@ module Aeternitas
|
|
68
83
|
# @param [ActiveSupport::Duration] duration sleeping duration
|
69
84
|
# @param [String] msg hint why the guard sleeps
|
70
85
|
def sleep_for(duration, msg = nil)
|
71
|
-
raise ArgumentError,
|
86
|
+
raise ArgumentError, "duration must be an ActiveRecord::Duration" unless duration.is_a?(ActiveSupport::Duration)
|
72
87
|
sleep_until(duration.from_now, msg)
|
73
88
|
end
|
74
89
|
|
75
90
|
private
|
76
91
|
|
77
92
|
# 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
93
|
# @raise [Aeternitas::Guard::GuardIsLocked] if the lock can not be acquired
|
89
94
|
def acquire_lock!
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
'token' => @token
|
125
|
-
}
|
126
|
-
|
127
|
-
Aeternitas.redis.set(@id, JSON.unparse(payload), ex: @cooldown.to_i)
|
95
|
+
retries = 0
|
96
|
+
begin
|
97
|
+
Aeternitas::GuardLock.transaction do
|
98
|
+
lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first
|
99
|
+
|
100
|
+
if lock
|
101
|
+
if lock.locked_until > Time.current
|
102
|
+
# Lock is still active
|
103
|
+
raise GuardIsLocked.new(@id, lock.locked_until, lock.reason)
|
104
|
+
else
|
105
|
+
# Lock has expired
|
106
|
+
lock.update!(
|
107
|
+
token: @token,
|
108
|
+
state: :processing,
|
109
|
+
locked_until: @timeout.from_now,
|
110
|
+
reason: nil
|
111
|
+
)
|
112
|
+
end
|
113
|
+
else
|
114
|
+
# Create new lock
|
115
|
+
Aeternitas::GuardLock.create!(
|
116
|
+
lock_key: @id,
|
117
|
+
token: @token,
|
118
|
+
state: :processing,
|
119
|
+
locked_until: @timeout.from_now
|
120
|
+
)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
rescue ActiveRecord::RecordNotUnique
|
124
|
+
# prevent infinite loops in unexpected scenarios
|
125
|
+
retries += 1
|
126
|
+
raise if retries > 4
|
127
|
+
retry
|
128
|
+
end
|
128
129
|
end
|
129
130
|
|
130
|
-
# Lets the guard sleep until the given
|
131
|
-
# This
|
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
|
-
# }
|
131
|
+
# Lets the guard sleep until the given time.
|
132
|
+
# This will create a new sleeping lock or update an existing one.
|
142
133
|
# @todo Should this raise an error if the lock is not owned by this instance?
|
143
134
|
# @param [Time] sleep_timeout for how long will the guard sleep
|
144
135
|
# @param [String] msg hint why the guard sleeps
|
145
136
|
def sleep(sleep_timeout, msg = nil)
|
146
|
-
|
147
|
-
|
148
|
-
'state' => 'sleeping',
|
149
|
-
'timeout' => @timeout,
|
150
|
-
'cooldown' => @cooldown,
|
151
|
-
'locked_until' => sleep_timeout
|
152
|
-
}
|
153
|
-
payload.merge(message: msg) if msg
|
137
|
+
Aeternitas::GuardLock.transaction do
|
138
|
+
lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first_or_initialize
|
154
139
|
|
155
|
-
|
156
|
-
|
140
|
+
lock.assign_attributes(
|
141
|
+
token: @token,
|
142
|
+
state: :sleeping,
|
143
|
+
locked_until: sleep_timeout,
|
144
|
+
reason: msg
|
145
|
+
)
|
157
146
|
|
158
|
-
|
159
|
-
|
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)
|
147
|
+
lock.save!
|
148
|
+
end
|
183
149
|
end
|
184
150
|
|
185
151
|
# Custom error class thrown when the lock can not be acquired
|
@@ -196,4 +162,4 @@ module Aeternitas
|
|
196
162
|
end
|
197
163
|
end
|
198
164
|
end
|
199
|
-
end
|
165
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require "active_record"
|
2
|
+
|
3
|
+
module Aeternitas
|
4
|
+
# Stores the state of a distributed lock in the database.
|
5
|
+
class GuardLock < ActiveRecord::Base
|
6
|
+
self.table_name = "aeternitas_guard_locks"
|
7
|
+
|
8
|
+
enum :state, {
|
9
|
+
processing: "processing",
|
10
|
+
cooldown: "cooldown",
|
11
|
+
sleeping: "sleeping"
|
12
|
+
}
|
13
|
+
|
14
|
+
validates :lock_key, presence: true, uniqueness: true
|
15
|
+
validates :locked_until, presence: true
|
16
|
+
validates :token, presence: true
|
17
|
+
validates :state, presence: true, inclusion: {in: states.keys}
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Aeternitas
|
2
|
+
# Provides methods for cleaning up Aeternitas data.
|
3
|
+
module Maintenance
|
4
|
+
def self.cleanup_all
|
5
|
+
cleanup_stale_locks
|
6
|
+
cleanup_old_metrics
|
7
|
+
end
|
8
|
+
|
9
|
+
# Clean up stale job and guard locks that have passed their expiration.
|
10
|
+
def self.cleanup_stale_locks
|
11
|
+
logger = ActiveJob::Base.logger
|
12
|
+
logger.info "Cleaning up stale Aeternitas locks..."
|
13
|
+
|
14
|
+
unique_job_locks_deleted = Aeternitas::UniqueJobLock.where("expires_at < ?", Time.current).delete_all
|
15
|
+
guard_locks_deleted = Aeternitas::GuardLock.where("locked_until < ?", Time.current).delete_all
|
16
|
+
|
17
|
+
logger.info " - Deleted #{unique_job_locks_deleted} stale unique job locks."
|
18
|
+
logger.info " - Deleted #{guard_locks_deleted} stale guard locks."
|
19
|
+
logger.info "Stale lock cleanup complete."
|
20
|
+
end
|
21
|
+
|
22
|
+
# Clean up old metric records to prevent the table from growing too large.
|
23
|
+
def self.cleanup_old_metrics
|
24
|
+
logger = ActiveJob::Base.logger
|
25
|
+
cutoff_date = Aeternitas.config.metric_retention_period.ago
|
26
|
+
|
27
|
+
logger.info "Cleaning up Aeternitas metrics older than #{cutoff_date}..."
|
28
|
+
|
29
|
+
metrics_deleted = Aeternitas::Metric.where("created_at < ?", cutoff_date).delete_all
|
30
|
+
|
31
|
+
logger.info " - Deleted #{metrics_deleted} old metric records."
|
32
|
+
logger.info "Old metrics cleanup complete."
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require "active_record"
|
2
|
+
|
3
|
+
module Aeternitas
|
4
|
+
# Stores a single metric data point in the database.
|
5
|
+
class Metric < ActiveRecord::Base
|
6
|
+
self.table_name = "aeternitas_metrics"
|
7
|
+
|
8
|
+
validates :name, presence: true
|
9
|
+
validates :pollable_class, presence: true
|
10
|
+
validates :value, presence: true
|
11
|
+
end
|
12
|
+
end
|
data/lib/aeternitas/metrics.rb
CHANGED
@@ -1,18 +1,14 @@
|
|
1
|
-
require 'aeternitas/metrics/ten_minutes_resolution'
|
2
|
-
require 'aeternitas/metrics/counter'
|
3
|
-
require 'aeternitas/metrics/values'
|
4
|
-
require 'aeternitas/metrics/ratio'
|
5
|
-
|
6
1
|
module Aeternitas
|
7
|
-
# Provides
|
8
|
-
# Every metric is scoped by pollable class.
|
2
|
+
# Provides a simplified metrics system for Aeternitas.
|
3
|
+
# Every metric is scoped by pollable class and logged in the `aeternitas_metrics` table.
|
4
|
+
#
|
9
5
|
# Available metrics are:
|
10
6
|
# - polls => Number of polling runs
|
11
7
|
# - successful_polls => Number of successful polling runs
|
12
8
|
# - failed_polls => Number of failed polling runs (includes IgnoredErrors,
|
13
9
|
# excludes deactivation errors and Lock errors)
|
14
10
|
# - ignored_errors => Number of raised {Aeternitas::Errors::Ignored}
|
15
|
-
# -
|
11
|
+
# - deactivations => Number of deactivations
|
16
12
|
# - execution_time => Job execution time in seconds
|
17
13
|
# - guard_locked => Number of encountered locked guards
|
18
14
|
# - guard_timeout => Time until the guard is unlocked in seconds
|
@@ -20,143 +16,65 @@ module Aeternitas
|
|
20
16
|
# - pollables_created => Number of created pollables
|
21
17
|
# - sources_created => Number of created sources
|
22
18
|
#
|
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
19
|
# @example
|
31
|
-
# Aeternitas::Metrics.polls MyPollable
|
32
|
-
# Aeternitas::Metrics.
|
33
|
-
#
|
20
|
+
# Aeternitas::Metrics.log(:polls, MyPollable)
|
21
|
+
# Aeternitas::Metrics.log_value(:execution_time, MyPollable, 1.25)
|
22
|
+
#
|
23
|
+
# # Get all poll counts for MyPollable in the last day
|
24
|
+
# Aeternitas::Metrics.get(:polls, MyPollable, from: 1.day.ago)
|
25
|
+
#
|
34
26
|
module Metrics
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
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
|
27
|
+
# A list of all available metric names.
|
28
|
+
AVAILABLE_METRICS = [
|
29
|
+
:polls,
|
30
|
+
:successful_polls,
|
31
|
+
:failed_polls,
|
32
|
+
:ignored_errors,
|
33
|
+
:deactivations,
|
34
|
+
:execution_time,
|
35
|
+
:guard_locked,
|
36
|
+
:guard_timeout,
|
37
|
+
:guard_timeout_exceeded,
|
38
|
+
:sources_created,
|
39
|
+
:pollables_created
|
40
|
+
].freeze
|
60
41
|
|
61
|
-
|
62
|
-
|
63
|
-
|
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
|
42
|
+
# Logs a counter metric. This creates a new metric record with a value of 1.
|
43
|
+
# @param [Symbol] name the metric name
|
44
|
+
# @param [Class] pollable_class the class of the pollable
|
72
45
|
def self.log(name, pollable_class)
|
73
|
-
|
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
|
46
|
+
log_value(name, pollable_class, 1)
|
79
47
|
end
|
80
48
|
|
81
|
-
# Logs a value
|
82
|
-
# @param [Symbol
|
83
|
-
# @param [
|
84
|
-
# @param [
|
49
|
+
# Logs a value-based metric.
|
50
|
+
# @param [Symbol] name the metric name
|
51
|
+
# @param [Class] pollable_class the class of the pollable
|
52
|
+
# @param [Float] value the value to log
|
85
53
|
def self.log_value(name, pollable_class, value)
|
86
|
-
|
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
|
54
|
+
return unless Aeternitas.config.metrics_enabled && AVAILABLE_METRICS.include?(name)
|
114
55
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
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}"
|
56
|
+
Aeternitas::Metric.create(
|
57
|
+
name: name.to_s,
|
58
|
+
pollable_class: pollable_class.name,
|
59
|
+
value: value,
|
60
|
+
created_at: Time.now
|
61
|
+
)
|
62
|
+
rescue
|
63
|
+
# Metrics should fail silently
|
147
64
|
end
|
148
65
|
|
149
|
-
#
|
150
|
-
# @param [
|
151
|
-
# @param [
|
152
|
-
# @
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
66
|
+
# Retrieves metric records.
|
67
|
+
# @param [Symbol] name the metric
|
68
|
+
# @param [Class] pollable_class the pollable class
|
69
|
+
# @param [Time] from begin of the time frame
|
70
|
+
# @param [Time] to end of the timeframe
|
71
|
+
# @return [ActiveRecord::Relation] a relation of Aeternitas::Metric records
|
72
|
+
def self.get(name, pollable_class, from: 1.hour.ago, to: Time.now)
|
73
|
+
Aeternitas::Metric.where(
|
74
|
+
name: name.to_s,
|
75
|
+
pollable_class: pollable_class.name,
|
76
|
+
created_at: from..to
|
77
|
+
).order(:created_at)
|
160
78
|
end
|
161
79
|
end
|
162
|
-
end
|
80
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
require_relative "aeternitas_job"
|
2
|
+
require "digest"
|
3
|
+
|
4
|
+
module Aeternitas
|
5
|
+
# ActiveJob worker responsible for executing the polling.
|
6
|
+
class PollJob < AeternitasJob
|
7
|
+
queue_as :polling
|
8
|
+
|
9
|
+
LOCK_EXPIRATION = 1.month
|
10
|
+
RETRY_DELAYS = [60.seconds, 1.hour, 1.day, 1.week].freeze
|
11
|
+
MAX_TOTAL_ATTEMPTS = 5
|
12
|
+
|
13
|
+
# === Job Uniqueness ===
|
14
|
+
before_enqueue do |job|
|
15
|
+
# Only check for uniqueness on the first attempt. Retries should not be blocked by their own lock.
|
16
|
+
if job.executions.zero?
|
17
|
+
pollable_meta_data_id = job.arguments.first
|
18
|
+
pollable_meta_data = Aeternitas::PollableMetaData.find(pollable_meta_data_id)
|
19
|
+
pollable = pollable_meta_data.pollable
|
20
|
+
|
21
|
+
lock_digest = self.class.generate_lock_digest(pollable_meta_data_id)
|
22
|
+
guard_key_digest = self.class.generate_guard_key_digest(pollable)
|
23
|
+
|
24
|
+
Aeternitas::UniqueJobLock.where("lock_digest = ? AND expires_at <= ?", lock_digest, Time.now).destroy_all
|
25
|
+
|
26
|
+
new_lock = Aeternitas::UniqueJobLock.new(
|
27
|
+
lock_digest: lock_digest,
|
28
|
+
guard_key_digest: guard_key_digest,
|
29
|
+
expires_at: Time.now + LOCK_EXPIRATION,
|
30
|
+
job_id: job.job_id
|
31
|
+
)
|
32
|
+
|
33
|
+
unless new_lock.save
|
34
|
+
ActiveJob::Base.logger.warn "[Aeternitas::PollJob] Aborting enqueue for #{pollable_meta_data_id} (job #{job.job_id}) due to existing lock: #{lock_digest}"
|
35
|
+
throw(:abort)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# === Retry Configuration (using standard ActiveJob) ===
|
41
|
+
retry_on StandardError,
|
42
|
+
attempts: MAX_TOTAL_ATTEMPTS,
|
43
|
+
wait: ->(executions) { execution_wait_time(executions) },
|
44
|
+
jitter: ->(executions) { [execution_wait_time(executions) * 0.1, 10.minutes].min } do |job, error|
|
45
|
+
handle_retries_exhausted(error)
|
46
|
+
end
|
47
|
+
|
48
|
+
# === GuardIsLocked Handling ===
|
49
|
+
rescue_from Aeternitas::Guard::GuardIsLocked do |error|
|
50
|
+
meta_data = Aeternitas::PollableMetaData.find_by(id: arguments.first)
|
51
|
+
return unless meta_data
|
52
|
+
|
53
|
+
pollable = meta_data.pollable
|
54
|
+
pollable_config = pollable.pollable_configuration
|
55
|
+
base_delay = (error.timeout - Time.now).to_f
|
56
|
+
meta_data.enqueue!
|
57
|
+
|
58
|
+
if pollable_config.sleep_on_guard_locked
|
59
|
+
if base_delay > 0
|
60
|
+
ActiveJob::Base.logger.warn "[Aeternitas::PollJob] Guard locked for #{arguments.first}. Sleep for #{base_delay.round(2)}s."
|
61
|
+
sleep(base_delay)
|
62
|
+
end
|
63
|
+
retry_job
|
64
|
+
else
|
65
|
+
guard_key_digest = self.class.generate_guard_key_digest(pollable)
|
66
|
+
lock_digest = self.class.generate_lock_digest(arguments.first)
|
67
|
+
unique_lock = Aeternitas::UniqueJobLock.find_by(lock_digest: lock_digest)
|
68
|
+
|
69
|
+
rank = if unique_lock
|
70
|
+
Aeternitas::UniqueJobLock.where(guard_key_digest: guard_key_digest)
|
71
|
+
.where("created_at <= ?", unique_lock.created_at)
|
72
|
+
.count
|
73
|
+
else
|
74
|
+
Aeternitas::UniqueJobLock.where(guard_key_digest: guard_key_digest).count
|
75
|
+
end
|
76
|
+
|
77
|
+
stagger_delay = rank * pollable.guard.cooldown.to_f
|
78
|
+
jitter = rand(0.0..2.0)
|
79
|
+
total_wait = base_delay + stagger_delay + jitter
|
80
|
+
|
81
|
+
if total_wait > 0
|
82
|
+
retry_job(wait: total_wait.seconds)
|
83
|
+
ActiveJob::Base.logger.info "[Aeternitas::PollJob] Guard locked for #{arguments.first}. Retry in #{total_wait.round(2)}s."
|
84
|
+
else
|
85
|
+
# GuardLock expired, retry with minimal delay
|
86
|
+
retry_job(wait: jitter.seconds)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.execution_wait_time(executions)
|
92
|
+
wait_index = executions - 1
|
93
|
+
RETRY_DELAYS[wait_index] || RETRY_DELAYS.last
|
94
|
+
end
|
95
|
+
|
96
|
+
def perform(pollable_meta_data_id)
|
97
|
+
meta_data = Aeternitas::PollableMetaData.find_by(id: pollable_meta_data_id)
|
98
|
+
if meta_data
|
99
|
+
pollable = meta_data.pollable
|
100
|
+
pollable&.execute_poll
|
101
|
+
else
|
102
|
+
ActiveJob::Base.logger.warn "[Aeternitas::PollJob] PollableMetaData with ID #{pollable_meta_data_id} not found."
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
after_perform -> { cleanup_lock("success") }
|
107
|
+
|
108
|
+
def self.generate_lock_digest(pollable_meta_data_id)
|
109
|
+
Digest::SHA256.hexdigest("#{name}:#{pollable_meta_data_id}")
|
110
|
+
end
|
111
|
+
|
112
|
+
def self.generate_guard_key_digest(pollable)
|
113
|
+
guard_key = pollable.pollable_configuration.guard_options[:key].call(pollable)
|
114
|
+
Digest::SHA256.hexdigest("guard-key:#{guard_key}")
|
115
|
+
end
|
116
|
+
|
117
|
+
def handle_retries_exhausted(error)
|
118
|
+
ActiveJob::Base.logger.error "[Aeternitas::PollJob] Retries exhausted for job #{job_id}. Error: #{error&.class} - #{error&.message}"
|
119
|
+
pollable_meta_data_id = arguments.first
|
120
|
+
meta_data = Aeternitas::PollableMetaData.find_by(id: pollable_meta_data_id)
|
121
|
+
meta_data&.disable_polling("Retries exhausted. Last error: #{error&.message}")
|
122
|
+
cleanup_lock("retries_exhausted")
|
123
|
+
raise error
|
124
|
+
end
|
125
|
+
|
126
|
+
def cleanup_lock(reason = "unknown")
|
127
|
+
return unless arguments.is_a?(Array) && arguments.first
|
128
|
+
|
129
|
+
pollable_meta_data_id = arguments.first
|
130
|
+
digest = self.class.generate_lock_digest(pollable_meta_data_id)
|
131
|
+
lock = Aeternitas::UniqueJobLock.find_by(lock_digest: digest)
|
132
|
+
lock&.destroy
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|