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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.github/workflows/lint.yml +25 -0
  4. data/.github/workflows/tests.yml +28 -0
  5. data/.gitignore +2 -5
  6. data/.ruby-version +1 -1
  7. data/CHANGELOG.md +35 -0
  8. data/Gemfile +1 -1
  9. data/README.md +93 -148
  10. data/Rakefile +1 -1
  11. data/aeternitas.gemspec +23 -34
  12. data/lib/aeternitas/aeternitas_job.rb +7 -0
  13. data/lib/aeternitas/cleanup_old_metrics_job.rb +12 -0
  14. data/lib/aeternitas/cleanup_stale_locks_job.rb +12 -0
  15. data/lib/aeternitas/errors.rb +1 -2
  16. data/lib/aeternitas/guard.rb +72 -106
  17. data/lib/aeternitas/guard_lock.rb +19 -0
  18. data/lib/aeternitas/maintenance.rb +35 -0
  19. data/lib/aeternitas/metric.rb +12 -0
  20. data/lib/aeternitas/metrics.rb +54 -136
  21. data/lib/aeternitas/poll_job.rb +135 -0
  22. data/lib/aeternitas/pollable/configuration.rb +21 -22
  23. data/lib/aeternitas/pollable/dsl.rb +16 -17
  24. data/lib/aeternitas/pollable.rb +19 -18
  25. data/lib/aeternitas/pollable_meta_data.rb +18 -9
  26. data/lib/aeternitas/polling_frequency.rb +4 -4
  27. data/lib/aeternitas/source.rb +5 -5
  28. data/lib/aeternitas/storage_adapter/file.rb +9 -12
  29. data/lib/aeternitas/storage_adapter.rb +1 -3
  30. data/lib/aeternitas/unique_job_lock.rb +15 -0
  31. data/lib/aeternitas/version.rb +1 -1
  32. data/lib/aeternitas.rb +23 -26
  33. data/lib/generators/aeternitas/install_generator.rb +14 -8
  34. data/lib/generators/aeternitas/templates/add_aeternitas.rb.erb +34 -2
  35. data/lib/generators/aeternitas/templates/initializer.rb +10 -7
  36. metadata +35 -123
  37. data/.idea/.rakeTasks +0 -7
  38. data/.idea/misc.xml +0 -4
  39. data/.idea/modules.xml +0 -8
  40. data/.idea/vcs.xml +0 -6
  41. data/.rspec +0 -2
  42. data/.rubocop.yml +0 -2
  43. data/.travis.yml +0 -8
  44. data/lib/aeternitas/metrics/counter.rb +0 -18
  45. data/lib/aeternitas/metrics/ratio.rb +0 -67
  46. data/lib/aeternitas/metrics/ten_minutes_resolution.rb +0 -40
  47. data/lib/aeternitas/metrics/values.rb +0 -18
  48. data/lib/aeternitas/sidekiq/middleware.rb +0 -31
  49. data/lib/aeternitas/sidekiq/poll_job.rb +0 -30
  50. data/lib/aeternitas/sidekiq.rb +0 -5
  51. data/logo.png +0 -0
  52. data/logo.svg +0 -198
@@ -1,9 +1,9 @@
1
- require 'active_support/duration'
2
- require 'securerandom'
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 Redis key expiration we ensure locks are released even after workers crash after a configurable timout period.
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 = id
37
+ @id = id
39
38
  @cooldown = cooldown
40
- @timeout = timeout
41
- @token = SecureRandom.hex(10)
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::LockWithCooldown::GuardIsLocked] if the lock can not be acquired
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, 'duration must be an ActiveRecord::Duration' unless duration.is_a?(ActiveSupport::Duration)
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
- 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)
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 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
- # }
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
- 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
137
+ Aeternitas::GuardLock.transaction do
138
+ lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first_or_initialize
154
139
 
155
- Aeternitas.redis.set(@id, JSON.unparse(payload), ex: (sleep_timeout - Time.now).seconds.to_i)
156
- end
140
+ lock.assign_attributes(
141
+ token: @token,
142
+ state: :sleeping,
143
+ locked_until: sleep_timeout,
144
+ reason: msg
145
+ )
157
146
 
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)
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
@@ -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 extensive metrics for Aeternitas.
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
- # - deactivation_errors => Number of errors raised which are declared as deactivation_errors
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, from: 3.days.ago, to: Time.now, resolution: :hour
32
- # Aeternitas::Metrics.execution_times MyPollable
33
- # @see #get
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
- 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
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
- 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
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
- 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
46
+ log_value(name, pollable_class, 1)
79
47
  end
80
48
 
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
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
- 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
54
+ return unless Aeternitas.config.metrics_enabled && AVAILABLE_METRICS.include?(name)
114
55
 
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}"
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
- # 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
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