aeternitas 0.2.0 → 2.0.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.
Files changed (55) 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 +36 -0
  8. data/CODE_OF_CONDUCT.md +0 -0
  9. data/Gemfile +1 -1
  10. data/LICENSE.txt +0 -0
  11. data/README.md +104 -145
  12. data/Rakefile +1 -1
  13. data/aeternitas.gemspec +23 -34
  14. data/lib/aeternitas/aeternitas_job.rb +7 -0
  15. data/lib/aeternitas/cleanup_old_metrics_job.rb +12 -0
  16. data/lib/aeternitas/cleanup_stale_locks_job.rb +12 -0
  17. data/lib/aeternitas/errors.rb +1 -2
  18. data/lib/aeternitas/guard.rb +76 -109
  19. data/lib/aeternitas/guard_lock.rb +19 -0
  20. data/lib/aeternitas/maintenance.rb +35 -0
  21. data/lib/aeternitas/metric.rb +12 -0
  22. data/lib/aeternitas/metrics.rb +54 -136
  23. data/lib/aeternitas/poll_job.rb +139 -0
  24. data/lib/aeternitas/pollable/configuration.rb +21 -22
  25. data/lib/aeternitas/pollable/dsl.rb +16 -17
  26. data/lib/aeternitas/pollable.rb +19 -18
  27. data/lib/aeternitas/pollable_meta_data.rb +18 -9
  28. data/lib/aeternitas/polling_frequency.rb +4 -4
  29. data/lib/aeternitas/source.rb +5 -5
  30. data/lib/aeternitas/storage_adapter/file.rb +9 -12
  31. data/lib/aeternitas/storage_adapter.rb +1 -3
  32. data/lib/aeternitas/test.rb +13 -0
  33. data/lib/aeternitas/unique_job_lock.rb +15 -0
  34. data/lib/aeternitas/version.rb +1 -1
  35. data/lib/aeternitas.rb +36 -26
  36. data/lib/generators/aeternitas/install_generator.rb +14 -8
  37. data/lib/generators/aeternitas/templates/add_aeternitas.rb.erb +34 -2
  38. data/lib/generators/aeternitas/templates/initializer.rb +10 -7
  39. metadata +39 -123
  40. data/.idea/.rakeTasks +0 -7
  41. data/.idea/misc.xml +0 -4
  42. data/.idea/modules.xml +0 -8
  43. data/.idea/vcs.xml +0 -6
  44. data/.rspec +0 -2
  45. data/.rubocop.yml +0 -2
  46. data/.travis.yml +0 -8
  47. data/lib/aeternitas/metrics/counter.rb +0 -18
  48. data/lib/aeternitas/metrics/ratio.rb +0 -67
  49. data/lib/aeternitas/metrics/ten_minutes_resolution.rb +0 -40
  50. data/lib/aeternitas/metrics/values.rb +0 -18
  51. data/lib/aeternitas/sidekiq/middleware.rb +0 -31
  52. data/lib/aeternitas/sidekiq/poll_job.rb +0 -30
  53. data/lib/aeternitas/sidekiq.rb +0 -5
  54. data/logo.png +0 -0
  55. data/logo.svg +0 -198
@@ -0,0 +1,139 @@
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
+ delay = Aeternitas.test_mode? ? 0 : base_delay
60
+ if delay > 0
61
+ ActiveJob::Base.logger.warn "[Aeternitas::PollJob] Guard locked for #{arguments.first}. Sleep for #{base_delay.round(2)}s."
62
+ sleep(delay)
63
+ end
64
+ retry_job
65
+ else
66
+ guard_key_digest = self.class.generate_guard_key_digest(pollable)
67
+ lock_digest = self.class.generate_lock_digest(arguments.first)
68
+ unique_lock = Aeternitas::UniqueJobLock.find_by(lock_digest: lock_digest)
69
+
70
+ rank = if unique_lock
71
+ Aeternitas::UniqueJobLock.where(guard_key_digest: guard_key_digest)
72
+ .where("created_at <= ?", unique_lock.created_at)
73
+ .count
74
+ else
75
+ Aeternitas::UniqueJobLock.where(guard_key_digest: guard_key_digest).count
76
+ end
77
+
78
+ stagger_delay = rank * pollable.guard.cooldown.to_f
79
+ jitter = rand(0.0..2.0)
80
+ total_wait = base_delay + stagger_delay + jitter
81
+
82
+ if total_wait > 0 || Aeternitas.test_mode?
83
+ wait_time = Aeternitas.test_mode? ? 0.seconds : total_wait.seconds
84
+ retry_job(wait: wait_time)
85
+ ActiveJob::Base.logger.info "[Aeternitas::PollJob] Guard locked for #{arguments.first}. Retry in #{total_wait.round(2)}s."
86
+ else
87
+ # GuardLock expired, retry with minimal delay
88
+ wait_time = Aeternitas.test_mode? ? 0.seconds : jitter.seconds
89
+ retry_job(wait: wait_time)
90
+ end
91
+ end
92
+ end
93
+
94
+ def self.execution_wait_time(executions)
95
+ return 0.seconds if Aeternitas.test_mode?
96
+ wait_index = executions - 1
97
+ RETRY_DELAYS[wait_index] || RETRY_DELAYS.last
98
+ end
99
+
100
+ def perform(pollable_meta_data_id)
101
+ meta_data = Aeternitas::PollableMetaData.find_by(id: pollable_meta_data_id)
102
+ if meta_data
103
+ pollable = meta_data.pollable
104
+ pollable&.execute_poll
105
+ else
106
+ ActiveJob::Base.logger.warn "[Aeternitas::PollJob] PollableMetaData with ID #{pollable_meta_data_id} not found."
107
+ end
108
+ end
109
+
110
+ after_perform -> { cleanup_lock("success") }
111
+
112
+ def self.generate_lock_digest(pollable_meta_data_id)
113
+ Digest::SHA256.hexdigest("#{name}:#{pollable_meta_data_id}")
114
+ end
115
+
116
+ def self.generate_guard_key_digest(pollable)
117
+ guard_key = pollable.pollable_configuration.guard_options[:key].call(pollable)
118
+ Digest::SHA256.hexdigest("guard-key:#{guard_key}")
119
+ end
120
+
121
+ def handle_retries_exhausted(error)
122
+ ActiveJob::Base.logger.error "[Aeternitas::PollJob] Retries exhausted for job #{job_id}. Error: #{error&.class} - #{error&.message}"
123
+ pollable_meta_data_id = arguments.first
124
+ meta_data = Aeternitas::PollableMetaData.find_by(id: pollable_meta_data_id)
125
+ meta_data&.disable_polling("Retries exhausted. Last error: #{error&.message}")
126
+ cleanup_lock("retries_exhausted")
127
+ raise error
128
+ end
129
+
130
+ def cleanup_lock(reason = "unknown")
131
+ return unless arguments.is_a?(Array) && arguments.first
132
+
133
+ pollable_meta_data_id = arguments.first
134
+ digest = self.class.generate_lock_digest(pollable_meta_data_id)
135
+ lock = Aeternitas::UniqueJobLock.find_by(lock_digest: digest)
136
+ lock&.destroy
137
+ end
138
+ end
139
+ end
@@ -9,7 +9,7 @@ module Aeternitas
9
9
  # @!attribute [rw] after_polling
10
10
  # Methods to be run after each successful poll
11
11
  # @!attribute [rw] queue
12
- # Sidekiq queue the poll job will be enqueued in (Default: 'polling')
12
+ # The queue the poll job will be enqueued in (Default: 'polling')
13
13
  # @!attribute [rw] guard_options
14
14
  # Configuration of the pollables lock (Default: key => class+id, cooldown => 5.seconds, timeout => 10.minutes)
15
15
  # @!attribute [rw] deactivation_errors
@@ -18,17 +18,17 @@ module Aeternitas
18
18
  # Errors in this list will be wrapped by {Aeternitas::Error::Ignored} if they occur while polling
19
19
  # (i.e. ignore in your exception tracker)
20
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)
21
+ # When set to true, the ActiveJob worker thread will sleep until the
22
+ # lock is released if the lock could not be acquired. (Default: false)
23
23
  class Configuration
24
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
25
+ :before_polling,
26
+ :queue,
27
+ :polling_frequency,
28
+ :after_polling,
29
+ :guard_options,
30
+ :ignored_errors,
31
+ :sleep_on_guard_locked
32
32
 
33
33
  # Creates a new Configuration with default options
34
34
  def initialize
@@ -36,29 +36,28 @@ module Aeternitas
36
36
  @before_polling = []
37
37
  @after_polling = []
38
38
  @guard_options = {
39
- key: ->(obj) { return obj.class.name.to_s },
39
+ key: ->(obj) { obj.class.name.to_s },
40
40
  timeout: 10.minutes,
41
41
  cooldown: 5.seconds
42
42
  }
43
43
  @deactivation_errors = []
44
44
  @ignored_errors = []
45
- @queue = 'polling'
46
- @sleep_on_guard_locked = true
45
+ @queue = "polling"
46
+ @sleep_on_guard_locked = false
47
47
  end
48
48
 
49
49
  def copy
50
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
51
+ config.polling_frequency = polling_frequency
52
+ config.before_polling = before_polling.deep_dup
53
+ config.after_polling = after_polling.deep_dup
54
+ config.guard_options = guard_options.deep_dup
55
+ config.deactivation_errors = deactivation_errors.deep_dup
56
+ config.ignored_errors = ignored_errors.deep_dup
57
+ config.queue = queue
58
+ config.sleep_on_guard_locked = sleep_on_guard_locked
59
59
  config
60
60
  end
61
61
  end
62
62
  end
63
63
  end
64
-
@@ -21,10 +21,10 @@ module Aeternitas
21
21
  # polling_frequency ->(pollable) {Time.now + 1.month + Time.now - pollable.created_at.to_i / 3.month * 1.month}
22
22
  # @todo allow custom methods via reference
23
23
  def polling_frequency(frequency)
24
- if frequency.is_a?(Symbol)
25
- @configuration.polling_frequency = Aeternitas::PollingFrequency.by_name(frequency)
24
+ @configuration.polling_frequency = if frequency.is_a?(Symbol)
25
+ Aeternitas::PollingFrequency.by_name(frequency)
26
26
  else
27
- @configuration.polling_frequency = frequency
27
+ frequency
28
28
  end
29
29
  end
30
30
 
@@ -38,10 +38,10 @@ module Aeternitas
38
38
  # @example method by block
39
39
  # before_polling ->(pollable) {do_something}
40
40
  def before_polling(method)
41
- if method.is_a?(Symbol)
42
- @configuration.before_polling << ->(pollable) { pollable.send(method) }
41
+ @configuration.before_polling << if method.is_a?(Symbol)
42
+ ->(pollable) { pollable.send(method) }
43
43
  else
44
- @configuration.before_polling << method
44
+ method
45
45
  end
46
46
  end
47
47
 
@@ -55,10 +55,10 @@ module Aeternitas
55
55
  # @example method by block
56
56
  # after_polling ->(pollable) {do_something}
57
57
  def after_polling(method)
58
- if method.is_a?(Symbol)
59
- @configuration.after_polling << ->(pollable) { pollable.send(method) }
58
+ @configuration.after_polling << if method.is_a?(Symbol)
59
+ ->(pollable) { pollable.send(method) }
60
60
  else
61
- @configuration.after_polling << method
61
+ method
62
62
  end
63
63
  end
64
64
 
@@ -94,13 +94,13 @@ module Aeternitas
94
94
  # guard_key ->(pollable) {URI.parse(pollable.url).host}
95
95
  def guard_key(key)
96
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
97
+ when Symbol
98
+ ->(obj) { obj.send(key) }
99
+ when Proc
100
+ key
101
+ else
102
+ ->(obj) { key.to_s }
103
+ end
104
104
  end
105
105
 
106
106
  # Configure the guard.
@@ -121,4 +121,3 @@ module Aeternitas
121
121
  end
122
122
  end
123
123
  end
124
-
@@ -1,5 +1,5 @@
1
- require 'aeternitas/pollable/configuration'
2
- require 'aeternitas/pollable/dsl'
1
+ require "aeternitas/pollable/configuration"
2
+ require "aeternitas/pollable/dsl"
3
3
 
4
4
  module Aeternitas
5
5
  # Mixin that enables the frequent polling of the receiving class.
@@ -25,20 +25,20 @@ module Aeternitas
25
25
  extend ActiveSupport::Concern
26
26
 
27
27
  included do
28
- raise StandardError, 'Aeternitas::Pollable must inherit from ActiveRecord::Base' unless self.ancestors.include?(ActiveRecord::Base)
28
+ raise StandardError, "Aeternitas::Pollable must inherit from ActiveRecord::Base" unless ancestors.include?(ActiveRecord::Base)
29
29
 
30
30
  has_one :pollable_meta_data, as: :pollable,
31
- dependent: :destroy,
32
- class_name: 'Aeternitas::PollableMetaData'
31
+ dependent: :destroy,
32
+ class_name: "Aeternitas::PollableMetaData"
33
33
 
34
34
  has_many :sources, as: :pollable,
35
- dependent: :destroy,
36
- class_name: 'Aeternitas::Source'
35
+ dependent: :destroy,
36
+ class_name: "Aeternitas::Source"
37
37
 
38
38
  validates :pollable_meta_data, presence: true
39
39
 
40
40
  before_validation ->(pollable) do
41
- pollable.pollable_meta_data ||= pollable.build_pollable_meta_data(state: 'waiting')
41
+ pollable.pollable_meta_data ||= pollable.build_pollable_meta_data(state: "waiting")
42
42
  pollable.pollable_meta_data.pollable_class = pollable.class.name
43
43
  end
44
44
 
@@ -53,7 +53,10 @@ module Aeternitas
53
53
 
54
54
  begin
55
55
  guard.with_lock { poll }
56
- rescue StandardError => e
56
+ rescue Aeternitas::Guard::GuardIsLocked
57
+ # Do not transition to the 'errored' state for a guard lock.
58
+ raise
59
+ rescue => e
57
60
  if pollable_configuration.deactivation_errors.include?(e.class)
58
61
  disable_polling(e)
59
62
  return false
@@ -67,7 +70,7 @@ module Aeternitas
67
70
  end
68
71
 
69
72
  _after_poll
70
- rescue StandardError => e
73
+ rescue => e
71
74
  begin
72
75
  log_poll_error(e)
73
76
  ensure
@@ -75,8 +78,6 @@ module Aeternitas
75
78
  end
76
79
  end
77
80
 
78
-
79
-
80
81
  # This method implements the class specific polling behaviour.
81
82
  # It is only called after the lock was acquired successfully.
82
83
  #
@@ -91,8 +92,8 @@ module Aeternitas
91
92
  # {Aeternitas::Pollable} was included. Otherwise it is done automatically after creation.
92
93
  def register_pollable
93
94
  self.pollable_meta_data ||= create_pollable_meta_data(
94
- state: 'waiting',
95
- pollable_class: self.class.name
95
+ state: "waiting",
96
+ pollable_class: self.class.name
96
97
  )
97
98
  end
98
99
 
@@ -121,7 +122,7 @@ module Aeternitas
121
122
  # @param [String] raw_content the sources raw content
122
123
  # @return [Aeternitas::Source] the newly created or existing source
123
124
  def add_source(raw_content)
124
- source = self.sources.create(raw_content: raw_content)
125
+ source = sources.create(raw_content: raw_content)
125
126
  return nil unless source.persisted?
126
127
 
127
128
  Aeternitas::Metrics.log(:sources_created, self.class)
@@ -142,7 +143,7 @@ module Aeternitas
142
143
  # Run all postpolling methods
143
144
  def _after_poll
144
145
  pollable_meta_data.wait! do
145
- pollable_meta_data.update_attributes!(
146
+ pollable_meta_data.update!(
146
147
  last_polling: Time.now,
147
148
  next_polling: pollable_configuration.polling_frequency.call(self)
148
149
  )
@@ -185,7 +186,7 @@ module Aeternitas
185
186
  # Configure the polling process.
186
187
  # For available configuration options see {Aeternitas::Pollable::Configuration} and {Aeternitas::Pollable::DSL}
187
188
  def polling_options(&block)
188
- Aeternitas::Pollable::Dsl.new(self.pollable_configuration, &block)
189
+ Aeternitas::Pollable::Dsl.new(pollable_configuration, &block)
189
190
  end
190
191
 
191
192
  def inherited(other)
@@ -194,4 +195,4 @@ module Aeternitas
194
195
  end
195
196
  end
196
197
  end
197
- end
198
+ end
@@ -1,10 +1,10 @@
1
- require 'aasm'
1
+ require "aasm"
2
2
 
3
3
  module Aeternitas
4
4
  # Stores the meta data of all pollables
5
5
  # Every pollable needs to have exactly one meta data object
6
6
  class PollableMetaData < ActiveRecord::Base
7
- self.table_name = 'aeternitas_pollable_meta_data'
7
+ self.table_name = "aeternitas_pollable_meta_data"
8
8
 
9
9
  include AASM
10
10
  ######
@@ -25,20 +25,29 @@ module Aeternitas
25
25
 
26
26
  belongs_to :pollable, polymorphic: true
27
27
 
28
- validates :pollable_type, presence: true, uniqueness: { scope: :pollable_id }
29
- validates :pollable_id, presence: true, uniqueness: { scope: :pollable_type }
28
+ validates :pollable_type, presence: true, uniqueness: {scope: :pollable_id}
29
+ validates :pollable_id, presence: true, uniqueness: {scope: :pollable_type}
30
30
  validates :pollable_class, presence: true
31
31
  validates :next_polling, presence: true
32
32
 
33
33
  aasm column: :state do
34
+ # Only pollables in this state are picked up for enqueueing ('next_polling')
34
35
  state :waiting, initial: true
36
+
37
+ # A PollJob has been submitted and the pollable is waiting for a worker to start processing.
35
38
  state :enqueued
39
+
40
+ # A worker has picked up the job and is currently executing the pollable.
36
41
  state :active
42
+
43
+ # Polling has been permanently disabled (e.g., due to retries exhausted).
37
44
  state :deactivated
45
+
46
+ # A generic error occurred during polling. The job will be retried.
38
47
  state :errored
39
48
 
40
49
  event :enqueue do
41
- transitions from: %i[waiting deactivated errored], to: :enqueued
50
+ transitions from: %i[waiting deactivated errored active], to: :enqueued
42
51
  end
43
52
 
44
53
  event :poll do
@@ -58,16 +67,16 @@ module Aeternitas
58
67
  end
59
68
  end
60
69
 
61
- scope(:due, ->() { waiting.where('next_polling < ?', Time.now) })
70
+ scope(:due, -> { waiting.where("next_polling < ?", Time.now) })
62
71
 
63
72
  # Disables polling of this instance
64
73
  #
65
74
  # @param [String] reason Reason for the deactivation. (E.g. an error message)
66
75
  def disable_polling(reason = nil)
67
- self.deactivate
76
+ deactivate
68
77
  self.deactivation_reason = reason.to_s
69
78
  self.deactivated_at = Time.now
70
- self.save!
79
+ save!
71
80
  end
72
81
  end
73
- end
82
+ end
@@ -1,9 +1,9 @@
1
1
  module Aeternitas
2
2
  # Stores default polling frequency calculation methods.
3
3
  module PollingFrequency
4
- HOURLY = ->(context) { Time.now + 1.hour }
5
- DAILY = ->(context) { Time.now + 1.day }
6
- WEEKLY = ->(context) { Time.now + 1.week }
4
+ HOURLY = ->(context) { Time.now + 1.hour }
5
+ DAILY = ->(context) { Time.now + 1.day }
6
+ WEEKLY = ->(context) { Time.now + 1.week }
7
7
  MONTHLY = ->(context) { Time.now + 1.month }
8
8
 
9
9
  # Retrieves the build-in polling frequency methods by name.
@@ -21,4 +21,4 @@ module Aeternitas
21
21
  end
22
22
  end
23
23
  end
24
- end
24
+ end
@@ -13,7 +13,7 @@ module Aeternitas
13
13
  # end
14
14
  # add_index :aeternitas_sources, [:pollable_id, :pollable_type], name: 'aeternitas_pollable_source'
15
15
  ######
16
- self.table_name = 'aeternitas_sources'
16
+ self.table_name = "aeternitas_sources"
17
17
 
18
18
  attr_writer :raw_content
19
19
 
@@ -42,21 +42,21 @@ module Aeternitas
42
42
  # Get the sources raw content.
43
43
  # @return [String] the sources raw content
44
44
  def raw_content
45
- @raw_content ||= Aeternitas.config.get_storage_adapter.retrieve(self.fingerprint)
45
+ @raw_content ||= Aeternitas.config.get_storage_adapter.retrieve(fingerprint)
46
46
  end
47
47
 
48
48
  private
49
49
 
50
50
  def create_file
51
- Aeternitas.config.get_storage_adapter.store(self.fingerprint, raw_content)
51
+ Aeternitas.config.get_storage_adapter.store(fingerprint, raw_content)
52
52
  end
53
53
 
54
54
  def delete_file
55
- Aeternitas.config.get_storage_adapter.delete(self.fingerprint)
55
+ Aeternitas.config.get_storage_adapter.delete(fingerprint)
56
56
  end
57
57
 
58
58
  def ensure_fingerprint
59
59
  self.fingerprint ||= generate_fingerprint
60
60
  end
61
61
  end
62
- end
62
+ end
@@ -2,7 +2,6 @@ module Aeternitas
2
2
  class StorageAdapter
3
3
  # A storage adapter that stores the entries on disk.
4
4
  class File < Aeternitas::StorageAdapter
5
-
6
5
  # Create a new File storage adapter.
7
6
  # @param [Hash] config the adapters config
8
7
  # @option config [String] :directory specifies where the entries are stored
@@ -14,22 +13,20 @@ module Aeternitas
14
13
  path = file_path(id)
15
14
  ensure_folders_exist(path)
16
15
  raise(Aeternitas::Errors::SourceDataExists, id) if ::File.exist?(path)
17
- ::File.open(path, 'w+', encoding: 'ascii-8bit') do |f|
16
+ ::File.open(path, "w+", encoding: "ascii-8bit") do |f|
18
17
  f.write(Zlib.deflate(raw_content, Zlib::BEST_COMPRESSION))
19
18
  end
20
19
  end
21
20
 
22
21
  def retrieve(id)
23
22
  raise(Aeternitas::Errors::SourceDataNotFound, id) unless exist?(id)
24
- Zlib.inflate(::File.read(file_path(id), encoding: 'ascii-8bit'))
23
+ Zlib.inflate(::File.read(file_path(id), encoding: "ascii-8bit"))
25
24
  end
26
25
 
27
26
  def delete(id)
28
- begin
29
- !!::File.delete(file_path(id))
30
- rescue Errno::ENOENT => e
31
- return false
32
- end
27
+ !!::File.delete(file_path(id))
28
+ rescue Errno::ENOENT
29
+ false
33
30
  end
34
31
 
35
32
  def exist?(id)
@@ -57,9 +54,9 @@ module Aeternitas
57
54
  # @return [String] the entries location
58
55
  def file_path(id)
59
56
  ::File.join(
60
- @config[:directory],
61
- id[0..1], id[2..3], id[4..5],
62
- id[6..-1]
57
+ @config[:directory],
58
+ id[0..1], id[2..3], id[4..5],
59
+ id[6..]
63
60
  )
64
61
  end
65
62
 
@@ -70,4 +67,4 @@ module Aeternitas
70
67
  end
71
68
  end
72
69
  end
73
- end
70
+ end
@@ -3,7 +3,6 @@ module Aeternitas
3
3
  # Storage Adapters take care of handling source files.
4
4
  # @abstract Create a subclass and override {#store}, #{retrieve} and #{#delete} to create a new storage adapter
5
5
  class StorageAdapter
6
-
7
6
  # Create a new storage adapter
8
7
  # @param [Hash] config the adapters configuration
9
8
  def initialize(config)
@@ -41,6 +40,5 @@ module Aeternitas
41
40
  def exist?(id)
42
41
  raise NotImplementedError, "#{self.class.name} does not implement #exist?, required by Aeternitas::StorageAdapter"
43
42
  end
44
-
45
43
  end
46
- end
44
+ end
@@ -0,0 +1,13 @@
1
+ module Aeternitas
2
+ # Provides test helpers for aeternitas
3
+ module Test
4
+ # Executes a block of code in test mode; all cooldowns and wait times are set to 0.
5
+ def self.test_mode
6
+ original_mode = Aeternitas.test_mode?
7
+ Aeternitas.test_mode = true
8
+ yield
9
+ ensure
10
+ Aeternitas.test_mode = original_mode
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ require "active_record"
2
+
3
+ module Aeternitas
4
+ # Since ActiveJob lacks a built-in uniqueness feature, this model prevents duplicate jobs from running simultaneously.
5
+ # This is achieved using the unique key `lock_digest`, which is calculated for each job instance.
6
+ # Additionally, this model stores `guard_key_digest` — a hash of the pollable's guard key —
7
+ # to determine how many jobs are waiting on the same resource and to adjust retries accordingly.
8
+ # Finally, `expires_at` is used to clean up stale locks left by crashed workers.
9
+ class UniqueJobLock < ActiveRecord::Base
10
+ self.table_name = "aeternitas_unique_job_locks"
11
+
12
+ validates :lock_digest, presence: true, uniqueness: true
13
+ validates :expires_at, presence: true
14
+ end
15
+ end
@@ -1,3 +1,3 @@
1
1
  module Aeternitas
2
- VERSION = "0.2.0"
2
+ VERSION = "2.0.0"
3
3
  end