diff --git a/README.md b/README.md index d3541d0..f8cf758 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,20 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). +### Testing + +Æternitas provides a test mode to help write tests. When enabled, all cooldowns, retry delays, and sleep durations are set to zero. This prevents your test suite from having to wait for scheduled delays. + +To enable test mode for a specific block of code, use the `Aeternitas::Test.test_mode` helper: + +```ruby +Aeternitas::Test.test_mode do + # ... +end +``` + +This ensures that test mode is enabled only for the duration of the block and is automatically disabled afterward. + ## Contributing Bug reports and spec backed pull requests are welcome on GitHub at https://github.com/Dietech-Group/aeternitas. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. diff --git a/lib/aeternitas.rb b/lib/aeternitas.rb index 2c29caf..9d2ff15 100644 --- a/lib/aeternitas.rb +++ b/lib/aeternitas.rb @@ -17,6 +17,7 @@ require "aeternitas/poll_job" require "aeternitas/cleanup_stale_locks_job" require "aeternitas/cleanup_old_metrics_job" +require "aeternitas/test" # Aeternitas module Aeternitas @@ -33,6 +34,18 @@ def self.configure yield(config) end + # Returns true if aeternitas is in test mode. + # @return [Boolean] + def self.test_mode? + @test_mode == true + end + + # Sets the test mode. + # @param [Boolean] value + def self.test_mode=(value) + @test_mode = value + end + # Enqueues all active pollables for which next polling is lower than the current time def self.enqueue_due_pollables Aeternitas::PollableMetaData.due.find_each do |pollable_meta_data| diff --git a/lib/aeternitas/guard.rb b/lib/aeternitas/guard.rb index 9bcdbd9..d142b3d 100644 --- a/lib/aeternitas/guard.rb +++ b/lib/aeternitas/guard.rb @@ -35,7 +35,7 @@ class Guard # @return [Aeternitas::Guard] Creates a new Instance def initialize(id, cooldown, timeout = 10.minutes) @id = id - @cooldown = cooldown + @cooldown = Aeternitas.test_mode? ? 0.seconds : cooldown @timeout = timeout @token = SecureRandom.hex(10) end @@ -134,6 +134,7 @@ def acquire_lock! # @param [Time] sleep_timeout for how long will the guard sleep # @param [String] msg hint why the guard sleeps def sleep(sleep_timeout, msg = nil) + sleep_timeout = Time.now if Aeternitas.test_mode? Aeternitas::GuardLock.transaction do lock = Aeternitas::GuardLock.where(lock_key: @id).lock.first_or_initialize diff --git a/lib/aeternitas/poll_job.rb b/lib/aeternitas/poll_job.rb index 69e8e5d..60d1de5 100644 --- a/lib/aeternitas/poll_job.rb +++ b/lib/aeternitas/poll_job.rb @@ -56,9 +56,10 @@ class PollJob < AeternitasJob meta_data.enqueue! if pollable_config.sleep_on_guard_locked - if base_delay > 0 + delay = Aeternitas.test_mode? ? 0 : base_delay + if delay > 0 ActiveJob::Base.logger.warn "[Aeternitas::PollJob] Guard locked for #{arguments.first}. Sleep for #{base_delay.round(2)}s." - sleep(base_delay) + sleep(delay) end retry_job else @@ -78,17 +79,20 @@ class PollJob < AeternitasJob jitter = rand(0.0..2.0) total_wait = base_delay + stagger_delay + jitter - if total_wait > 0 - retry_job(wait: total_wait.seconds) + if total_wait > 0 || Aeternitas.test_mode? + wait_time = Aeternitas.test_mode? ? 0.seconds : total_wait.seconds + retry_job(wait: wait_time) ActiveJob::Base.logger.info "[Aeternitas::PollJob] Guard locked for #{arguments.first}. Retry in #{total_wait.round(2)}s." else # GuardLock expired, retry with minimal delay - retry_job(wait: jitter.seconds) + wait_time = Aeternitas.test_mode? ? 0.seconds : jitter.seconds + retry_job(wait: wait_time) end end end def self.execution_wait_time(executions) + return 0.seconds if Aeternitas.test_mode? wait_index = executions - 1 RETRY_DELAYS[wait_index] || RETRY_DELAYS.last end diff --git a/lib/aeternitas/test.rb b/lib/aeternitas/test.rb new file mode 100644 index 0000000..2b6e738 --- /dev/null +++ b/lib/aeternitas/test.rb @@ -0,0 +1,13 @@ +module Aeternitas + # Provides test helpers for aeternitas + module Test + # Executes a block of code in test mode; all cooldowns and wait times are set to 0. + def self.test_mode + original_mode = Aeternitas.test_mode? + Aeternitas.test_mode = true + yield + ensure + Aeternitas.test_mode = original_mode + end + end +end diff --git a/spec/aeternitas/guard_spec.rb b/spec/aeternitas/guard_spec.rb index 3543df4..ad9825a 100644 --- a/spec/aeternitas/guard_spec.rb +++ b/spec/aeternitas/guard_spec.rb @@ -173,4 +173,22 @@ end end end + + describe "in test mode" do + around do |example| + Aeternitas::Test.test_mode do + example.run + end + end + + it "initializes with a cooldown of 0" do + expect(guard.cooldown).to eq(0.seconds) + end + + it "sleeps until now" do + guard.sleep_until(1.hour.from_now) + lock_record = Aeternitas::GuardLock.find_by(lock_key: lock_key) + expect(lock_record.locked_until).to be_within(1.second).of(Time.now) + end + end end diff --git a/spec/aeternitas/poll_job_spec.rb b/spec/aeternitas/poll_job_spec.rb index 12bfcfc..a000c7c 100644 --- a/spec/aeternitas/poll_job_spec.rb +++ b/spec/aeternitas/poll_job_spec.rb @@ -175,4 +175,34 @@ end end end + + describe "in test mode" do + around do |example| + Aeternitas::Test.test_mode do + example.run + end + end + + let(:guard_locked_error) { Aeternitas::Guard::GuardIsLocked.new("guard-key", 30.minutes.from_now) } + + it "uses a wait time of 0 for standard retries" do + expect(described_class.execution_wait_time(1)).to eq(0.seconds) + end + + context "when a guard is locked" do + before do + allow_any_instance_of(Aeternitas::Guard).to receive(:with_lock).and_raise(guard_locked_error) + end + + it "retries the job with a wait time of 0" do + travel_to Time.current do + described_class.perform_later(meta_data.id) + perform_enqueued_jobs + expect(enqueued_jobs.size).to eq(1) + enqueued_job = enqueued_jobs.last + expect(Time.at(enqueued_job[:at])).to be_within(1.second).of(Time.current) + end + end + end + end end