diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c7cda2b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.{diff}] +trim_trailing_whitespace = false diff --git a/.github/workflows/test-scheduler-lock.yml b/.github/workflows/test-scheduler-lock.yml new file mode 100644 index 0000000..727bb9b --- /dev/null +++ b/.github/workflows/test-scheduler-lock.yml @@ -0,0 +1,67 @@ +on: + push: + branches: [$default-branch] + pull_request: +jobs: + test-scheduler-lock: + runs-on: ubuntu-latest + services: + # Label used to access the service container + redis: + # Docker Hub image + image: redis + # Set health checks to wait until redis has started + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + # Maps port 6379 on service container to the host + - "6379:6379" + strategy: + fail-fast: false + matrix: + ruby-version: + - "3.0" + - "3.1" + - "3.2" + - "3.3" + resque-version: + - "~> 1.23" + redis-version: + - "~> 3.3" + - "~> 4.8" + resque-scheduler-version: + - "4.3.0" + - "< 4.9.0" + - "~> 4.9" + - "https://github.com/Ibotta/resque-scheduler.git@enqueue-multi-rollback" + resque-lock-timeout-version: + - "latest" + - "https://github.com/Ibotta/resque-lock-timeout.git@v0.5.0-ibotta" + - "https://github.com/Ibotta/resque-lock-timeout.git@tests-with-scheduler" + exclude: + # resque-scheduler (= 4.3.0) depends on redis (~> 3.3) + - redis-version: "~> 4.8" + resque-scheduler-version: "4.3.0" + env: + REDIS_VERSION: "${{ matrix.redis-version }}" + RESQUE: "${{ matrix.resque-version }}" + RESQUE_SCHEDULER_VERSION: "${{ matrix.resque-scheduler-version }}" + RESQUE_LOCK_TIMEOUT_VERSION: "${{ matrix.resque-lock-timeout-version }}" + # The hostname used to communicate with the Redis service container + REDIS_TEST_HOST: localhost + # The default Redis port + REDIS_TEST_PORT: 6379 + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + working-directory: scheduler-lock + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + - name: Run tests + working-directory: scheduler-lock + run: bundle exec rake diff --git a/.gitignore b/.gitignore index e3200e0..cda7ac0 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ build-iPhoneSimulator/ # Used by RuboCop. Remote config files pulled in from inherit_from directive. # .rubocop-https?--* +stdout +dump.rdb +coverage +Gemfile.lock diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..08c3161 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at osscompliance@ibotta.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000..09b1577 --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,51 @@ +## 0.5.0 (Ibotta) (2023-01-23) + +- Fix: normalize args when creating identifier +- Fix: adjust test with concurrency issues +- Maintenance: Update gem dependencies to support rubies >= 2.7 +- Maintenance: test against redis gem 3, 4 +- Maintenance: run in github actions + +## 0.4.5 (2015-08-04) + +- Fix: ensure exceptions are kept if raised **after** lock timeout expires. + +## 0.4.4 (2014-02-21) + +- Add: `enqueued?` and `loner_locked?` helper methods. +- Bump minimum version of resque to v1.22 + +## 0.4.1 (2012-11-19) + +- Fix: allow `@loner` job to be enqueued if timeout expires. + +## 0.4.0 (2012-11-09) + +- Add: `@loner` boolean option to prevent job being enqueued if already + running/enqueued. (Thanks to @ssaunier) + +## 0.3.3 (2012-03-09) + +- Tested against v1.20.0 of resque. + +## 0.3.1 (2011-07-16) + +- Pass job arguments to `lock_timeout`. (Bob Potter) +- Added `refresh_lock!` method for long running jobs. (Bob Potter) + +## 0.3.0 (2011-07-16) + +- Ability to customize redis connection used for storing locks. + (thanks Richie Vos =)) +- Added Bundler `Gemfile`. +- Added abstract stub methods for callback methods: + `lock_failed`, `lock_expired_before_release` + +## 0.2.1 (2010-06-16) + +- Relax gemspec dependancies. + +## 0.2.0 (2010-05-05) + +- Initial release as `resque-lock-timeout`, forked from Chris Wanstrath' + `resque-lock`. diff --git a/scheduler-lock/Gemfile b/scheduler-lock/Gemfile new file mode 100644 index 0000000..0018697 --- /dev/null +++ b/scheduler-lock/Gemfile @@ -0,0 +1,60 @@ +source "https://rubygems.org" + +case resque_version = ENV.fetch("RESQUE", "latest") +when "master" + gem "resque", git: "https://github.com/resque/resque" +when /^git:/, /^https:/ + gem "resque", git: resque_version +when "latest" + gem "resque" +else + versions = resque_version.split(",") + gem "resque", *versions +end + +case redis_version = ENV.fetch("REDIS_VERSION", "latest") +when "master" + gem "redis", git: "https://github.com/redis/redis-rb" +when /^git:/, /^https:/ + gem "redis", git: redis_version +when "latest" + gem "redis" +else + versions = redis_version.split(",") + gem "redis", *versions +end + +case resque_scheduler_version = ENV.fetch("RESQUE_SCHEDULER_VERSION", "latest") +when "master" + gem "resque-scheduler", git: "https://github.com/resque/resque-scheduler" +when /^git:/, /^https:/ + repo, ref = resque_scheduler_version.split("@", 2) + gem "resque-scheduler", git: repo, ref: ref +when "latest" + gem "resque-scheduler" +else + versions = resque_scheduler_version.split(",") + gem "resque-scheduler", *versions +end + +case resque_lock_timeout_version = ENV.fetch("RESQUE_LOCK_TIMEOUT_VERSION", "latest") +when "master" + gem "resque-lock-timeout", git: "https://github.com/Ibotta/resque-lock-timeout.git" +when /^git:/, /^https:/ + repo, ref = resque_lock_timeout_version.split("@", 2) + gem "resque-lock-timeout", git: repo, ref: ref +when "latest" + gem "resque-lock-timeout" +else + versions = resque_lock_timeout_version.split(",") + gem "resque-lock-timeout", *versions +end + +gem "rake" +gem "minitest" +gem "simplecov" +gem "debug" + +gem "standard", "~> 1.41" + +gem "logger", "~> 1.6" diff --git a/scheduler-lock/Rakefile b/scheduler-lock/Rakefile new file mode 100644 index 0000000..015ae62 --- /dev/null +++ b/scheduler-lock/Rakefile @@ -0,0 +1,9 @@ +require "rake/testtask" + +task default: :test + +desc "Run unit tests." +Rake::TestTask.new(:test) do |task| + task.test_files = FileList["test/*_test.rb"] + task.verbose = true +end diff --git a/scheduler-lock/test/delayed_job_test.rb b/scheduler-lock/test/delayed_job_test.rb new file mode 100644 index 0000000..30cb36d --- /dev/null +++ b/scheduler-lock/test/delayed_job_test.rb @@ -0,0 +1,130 @@ +require File.dirname(__FILE__) + "/test_helper" + +require "debug" +require "resque-scheduler" + +def scheduler_version_compare(version) + Gem::Requirement.create(version).satisfied_by?(Gem::Version.create(Resque::Scheduler::VERSION)) +end + +class LockTest < Minitest::Test + def setup + $success = $lock_failed = $lock_expired = $enqueue_failed = 0 + Resque::Scheduler.quiet = true + Resque.redis.redis.flushall + @worker = Resque::Worker.new(:test) + end + + def test_delayed_item_enqueue + t = Time.now + 60 + + # Resque::Scheduler.expects(:enqueue_next_item).never + + # create 90 jobs + 90.times { Resque.enqueue_at(t, LonelyJob) } + assert_equal(90, Resque.delayed_timestamp_size(t)) + + Resque::Scheduler.enqueue_delayed_items_for_timestamp(t) + assert_equal(0, Resque.delayed_timestamp_size(t)) + + # assert that the active queue has the lonely job + if scheduler_version_compare("< 4.9") + assert_equal(1, Resque.size(Resque.queue_from_class(LonelyJob))) + elsif Resque::Scheduler::VERSION.end_with?("-ibotta") + assert_equal(1, Resque.size(Resque.queue_from_class(LonelyJob))) + else + # this is asserting that > 4.9 "fails" without the patch + assert_equal(0, Resque.size(Resque.queue_from_class(LonelyJob))) + end + end +end + +if Resque::Scheduler::VERSION.end_with?("-ibotta") + class LockBatchOffTest < Minitest::Test + def setup + @batch_enabled = Resque::Scheduler.enable_delayed_requeue_batches + @batch_size = Resque::Scheduler.delayed_requeue_batch_size + + $success = $lock_failed = $lock_expired = $enqueue_failed = 0 + Resque::Scheduler.quiet = true + Resque.redis.redis.flushall + @worker = Resque::Worker.new(:test) + + Resque::Scheduler.enable_delayed_requeue_batches = false + Resque::Scheduler.delayed_requeue_batch_size = 1 + end + + def teardown + Resque::Scheduler.enable_delayed_requeue_batches = @batch_enabled + Resque::Scheduler.delayed_requeue_batch_size = @batch_size + end + + def test_delayed_item_enqueue + t = Time.now + 60 + + # Resque::Scheduler.expects(:enqueue_next_item).never + + # create 90 jobs + 90.times { Resque.enqueue_at(t, LonelyJob) } + assert_equal(90, Resque.delayed_timestamp_size(t)) + + Resque::Scheduler.enqueue_delayed_items_for_timestamp(t) + assert_equal(0, Resque.delayed_timestamp_size(t)) + + # assert that the active queue has the lonely job + if scheduler_version_compare("< 4.9") + assert_equal(1, Resque.size(Resque.queue_from_class(LonelyJob))) + elsif Resque::Scheduler::VERSION.end_with?("-ibotta") + # should act like before + assert_equal(1, Resque.size(Resque.queue_from_class(LonelyJob))) + else + # this is asserting that > 4.9 "fails" without the patch + assert_equal(0, Resque.size(Resque.queue_from_class(LonelyJob))) + end + end + end + + class LockBatchOnTest < Minitest::Test + def setup + @batch_enabled = Resque::Scheduler.enable_delayed_requeue_batches + @batch_size = Resque::Scheduler.delayed_requeue_batch_size + + $success = $lock_failed = $lock_expired = $enqueue_failed = 0 + Resque::Scheduler.quiet = true + Resque.redis.redis.flushall + @worker = Resque::Worker.new(:test) + + Resque::Scheduler.enable_delayed_requeue_batches = true + Resque::Scheduler.delayed_requeue_batch_size = 100 + end + + def teardown + Resque::Scheduler.enable_delayed_requeue_batches = @batch_enabled + Resque::Scheduler.delayed_requeue_batch_size = @batch_size + end + + def test_delayed_item_enqueue + t = Time.now + 60 + + # Resque::Scheduler.expects(:enqueue_next_item).never + + # create 90 jobs + 90.times { Resque.enqueue_at(t, LonelyJob) } + assert_equal(90, Resque.delayed_timestamp_size(t)) + + Resque::Scheduler.enqueue_delayed_items_for_timestamp(t) + assert_equal(0, Resque.delayed_timestamp_size(t)) + + # assert that the active queue has the lonely job + if scheduler_version_compare("< 4.9") + assert_equal(1, Resque.size(Resque.queue_from_class(LonelyJob))) + elsif Resque::Scheduler::VERSION.end_with?("-ibotta") + assert_equal(0, Resque.size(Resque.queue_from_class(LonelyJob))) + else + # this is asserting that > 4.9 "fails" without the patch + assert_equal(0, Resque.size(Resque.queue_from_class(LonelyJob))) + end + end + end + +end diff --git a/scheduler-lock/test/redis-test.conf b/scheduler-lock/test/redis-test.conf new file mode 100644 index 0000000..389351b --- /dev/null +++ b/scheduler-lock/test/redis-test.conf @@ -0,0 +1,115 @@ +# Redis configuration file example + +# By default Redis does not run as a daemon. Use 'yes' if you need it. +# Note that Redis will write a pid file in /var/run/redis.pid when daemonized. +daemonize yes + +# When run as a daemon, Redis write a pid file in /var/run/redis.pid by default. +# You can specify a custom pid file location here. +#pidfile ./test/redis-test.pid + +# Accept connections on the specified port, default is 6379 +port 9737 + +# If you want you can bind a single interface, if the bind option is not +# specified all the interfaces will listen for connections. +# +# bind 127.0.0.1 + +# Close the connection after a client is idle for N seconds (0 to disable) +timeout 300 + +# Save the DB on disk: +# +# save +# +# Will save the DB if both the given number of seconds and the given +# number of write operations against the DB occurred. +# +# In the example below the behaviour will be to save: +# after 900 sec (15 min) if at least 1 key changed +# after 300 sec (5 min) if at least 10 keys changed +# after 60 sec if at least 10000 keys changed +#save 900 1 +#save 300 10 +#save 60 10000 + +# The filename where to dump the DB +#dbfilename dump.rdb + +# For default save/load DB in/from the working directory +# Note that you must specify a directory not a file name. +#dir ./test/ + +# Set server verbosity to 'debug' +# it can be one of: +# debug (a lot of information, useful for development/testing) +# notice (moderately verbose, what you want in production probably) +# warning (only very important / critical messages are logged) +loglevel debug + +# Specify the log file name. Also 'stdout' can be used to force +# the demon to log on the standard output. Note that if you use standard +# output for logging but daemonize, logs will be sent to /dev/null +logfile stdout + +# Set the number of databases. The default database is DB 0, you can select +# a different one on a per-connection basis using SELECT where +# dbid is a number between 0 and 'databases'-1 +databases 16 + +################################# REPLICATION ################################# + +# Master-Slave replication. Use slaveof to make a Redis instance a copy of +# another Redis server. Note that the configuration is local to the slave +# so for example it is possible to configure the slave to save the DB with a +# different interval, or to listen to another port, and so on. + +# slaveof + +################################## SECURITY ################################### + +# Require clients to issue AUTH before processing any other +# commands. This might be useful in environments in which you do not trust +# others with access to the host running redis-server. +# +# This should stay commented out for backward compatibility and because most +# people do not need auth (e.g. they run their own servers). + +# requirepass foobared + +################################### LIMITS #################################### + +# Set the max number of connected clients at the same time. By default there +# is no limit, and it's up to the number of file descriptors the Redis process +# is able to open. The special value '0' means no limts. +# Once the limit is reached Redis will close all the new connections sending +# an error 'max number of clients reached'. + +# maxclients 128 + +# Don't use more memory than the specified amount of bytes. +# When the memory limit is reached Redis will try to remove keys with an +# EXPIRE set. It will try to start freeing keys that are going to expire +# in little time and preserve keys with a longer time to live. +# Redis will also try to remove objects from free lists if possible. +# +# If all this fails, Redis will start to reply with errors to commands +# that will use more memory, like SET, LPUSH, and so on, and will continue +# to reply to most read-only commands like GET. +# +# WARNING: maxmemory can be a good idea mainly if you want to use Redis as a +# 'state' server or cache, not as a real DB. When Redis is used as a real +# database the memory usage will grow over the weeks, it will be obvious if +# it is going to use too much memory in the long run, and you'll have the time +# to upgrade. With maxmemory after the limit is reached you'll start to get +# errors for write operations, and this may even lead to DB inconsistency. + +# maxmemory + +############################### ADVANCED CONFIG ############################### + +# Glue small output buffers together in order to send small replies in a +# single TCP packet. Uses a bit more CPU but most of the times it is a win +# in terms of number of queries per second. Use 'yes' if unsure. +# glueoutputbuf yes \ No newline at end of file diff --git a/scheduler-lock/test/test_helper.rb b/scheduler-lock/test/test_helper.rb new file mode 100644 index 0000000..3b4ca95 --- /dev/null +++ b/scheduler-lock/test/test_helper.rb @@ -0,0 +1,47 @@ +dir = File.dirname(File.expand_path(__FILE__)) +$LOAD_PATH.unshift dir + "/../lib" +$TESTING = true + +# Run code coverage in MRI 1.9 only. +if RUBY_VERSION >= "1.9" && RUBY_ENGINE == "ruby" + require "simplecov" + SimpleCov.start do + add_filter "/test/" + end +end + +require "minitest/pride" +require "minitest/autorun" + +require "resque-lock-timeout" +require dir + "/test_jobs" + +if ENV["REDIS_TEST_HOST"] && ENV["REDIS_TEST_PORT"] + Resque.redis = "#{ENV["REDIS_TEST_HOST"]}:#{ENV["REDIS_TEST_PORT"]}" +else + # make sure we can run redis-server + if !system("which redis-server") + puts "", "** `redis-server` was not found in your PATH" + abort "" + end + + # make sure we can shutdown the server using cli. + if !system("which redis-cli") + puts "", "** `redis-cli` was not found in your PATH" + abort "" + end + + puts "Starting redis for testing at localhost:9737..." + + # Start redis server for testing. + `redis-server #{dir}/redis-test.conf` + Resque.redis = "127.0.0.1:9737" + + # After tests are complete, make sure we shutdown redis. + Minitest.after_run { + `redis-cli -p 9737 shutdown nosave` + } + + ENV["REDIS_TEST_HOST"] = "127.0.0.1" + ENV["REDIS_TEST_PORT"] = "9737" +end diff --git a/scheduler-lock/test/test_jobs.rb b/scheduler-lock/test/test_jobs.rb new file mode 100644 index 0000000..b5efd7d --- /dev/null +++ b/scheduler-lock/test/test_jobs.rb @@ -0,0 +1,44 @@ +# Job that prevents the job being enqueued if already enqueued/running. +class LonelyJob + extend Resque::Plugins::LockTimeout + @queue = :test + @loner = true + + def self.perform + $success += 1 + sleep 0.2 + end + + def self.loner_enqueue_failed(*args) + $enqueue_failed += 1 + end +end + +# Exclusive job (only one queued/running) with a timeout. +class LonelyTimeoutJob + extend Resque::Plugins::LockTimeout + @queue = :test + @loner = true + @lock_timeout = 60 + + def self.perform + $success += 1 + sleep 0.2 + end + + def self.loner_enqueue_failed(*args) + $enqueue_failed += 1 + end +end + +# This job won't complete before it's timeout +class LonelyTimeoutExpiringJob + extend Resque::Plugins::LockTimeout + @queue = :test + @loner = true + @lock_timeout = 1 + + def self.perform + sleep 2 + end +end