gouda 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f36a7c7b361cb8008f34b4a178c6bf1eb2a358e95502d0550fc14fef78ef5ed7
4
- data.tar.gz: 6d2bc5778d6284f212189f4311e575393c5d9c1da13e4618f0b9a5d6ace9e954
3
+ metadata.gz: 0fa853c78222eb23897ccb31ed465fc231aa5894641fe0d1991ade90a5e3fc8d
4
+ data.tar.gz: e9680b441d3fe9c3da7fadf50272c033db712296104870d016b278da2c1d92bd
5
5
  SHA512:
6
- metadata.gz: 71a324a3bae3ee17c2ed547915b1cc841442059c30f0c85138f1b220a7c9dc2b307e6d0363fba12069be0b893ce74e1cefe81f5d3fc1ceec427b56a7331be256
7
- data.tar.gz: 8e9d521dc92ccd14175611e21535ed9e5b71610e74c22fd4038e11a16c104b9fc73d93431e1e9aae38bb37e739d91447f3739ddd8099083e4d6e821db1426ea5
6
+ metadata.gz: 9a64544cd45d14400ab949a848e0325ee5d5305d648f7f38239279f93e1f8d2d32dac368708317aafbf470b48e16f88a0ffe4bad6890798ef53adea0566da5f6
7
+ data.tar.gz: e2140d4da50c4afe8edadd51bb3049b60935e4c0273d235dcb1988efa2900362ea81451a58df04ba3f4db58209380ea9a58ccedd42972806bd5be2cd9f19d7d4
@@ -9,28 +9,34 @@ env:
9
9
  jobs:
10
10
  test:
11
11
  name: Tests
12
- runs-on: ubuntu-latest
12
+ runs-on: ubuntu-22.04
13
+ if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
14
+ strategy:
15
+ matrix:
16
+ ruby:
17
+ - '2.7'
18
+ - '3.3'
13
19
  services:
14
20
  postgres:
15
- image: postgres:15-alpine
21
+ image: postgres
16
22
  env:
17
23
  POSTGRES_PASSWORD: postgres
24
+ options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
18
25
  ports:
19
26
  - 5432:5432
20
- options: >-
21
- --health-cmd pg_isready
22
- --health-interval 100ms
23
- --health-timeout 1s
24
- --health-retries 100
25
27
 
26
- if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
27
28
  steps:
28
29
  - name: Checkout
29
30
  uses: actions/checkout@v4
30
31
  - name: Setup Ruby
31
32
  uses: ruby/setup-ruby@v1
32
33
  with:
33
- ruby-version: '3.2'
34
+ ruby-version: ${{ matrix.ruby }}
34
35
  bundler-cache: true
35
36
  - name: "Tests and Lint"
36
37
  run: bundle exec rake
38
+ env:
39
+ PGHOST: localhost
40
+ PGUSER: postgres
41
+ PGPASSWORD: postgres
42
+ TESTOPTS: "--fail-fast"
data/.gitignore CHANGED
@@ -5,5 +5,6 @@
5
5
  /doc/
6
6
  /pkg/
7
7
  /spec/reports/
8
- /tmp/
8
+ /tmp/*
9
+ !/tmp/.keep
9
10
  Gemfile.lock
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.2.2
1
+ 3.3.1
data/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2023-05-27
3
+ ## [0.1.0] - 2023-06-10
4
4
 
5
5
  - Initial release
6
6
 
7
+ ## [0.1.1] - 2023-06-10
8
+
9
+ - Fix support for older ruby versions until 2.7
10
+
11
+ ## [0.1.2] - 2023-06-11
12
+
13
+ - Updated readme and method renaming in Scheduler
data/README.md CHANGED
@@ -11,7 +11,96 @@ $ bundle install
11
11
  $ bin/rails g gouda:install
12
12
  ```
13
13
 
14
- ## Usage
14
+ Gouda is build as a lightweight alternative to [good_job](https://github.com/bensheldon/good_job) and has been created before [solid_queue.](https://github.com/rails/solid_queue/)
15
+ It is _smaller_ than solid_queue though.
16
+
17
+ It was designed to enable job processing using `SELECT ... FOR UPDATE SKIP LOCKED` on Postgres so that we could use pg_bouncer in our system setup.
18
+
19
+
20
+ ## Key concepts in Gouda: Workload
21
+
22
+ Gouda is built around the concept of a **Workload.** A workload is not the same as an ActiveJob. A workload is a single execution of a task - the task may be an entire ActiveJob, or a retry of an ActiveJob, or a part of a sequence of ActiveJobs initiated using [job-iteration](https://github.com/shopify/job-iteration)
23
+
24
+ You can easily have multiple `Workloads` stored in your queue which reference the same job. However, when you are using Gouda it is important to always keep the distinction between the two in mind.
25
+
26
+ When an ActiveJob gets first initialised, it receives a randomly-generated ActiveJob ID, which is normally a UUID. This UUID will be reused when a job gets retried, or when job-iteration is in use - but it will exist across multiple Gouda workloads.
27
+
28
+ A `Workload` can only be in one of the three states: `enqueued`, `executing` and `finished`. It does not matter whether the workload has raised an exception, or was manually canceled before it started performing, or succeeded - its terminal state is always going to be `finished`, regardless. This is done on purpose: Gouda uses a number of partial indexes in Postgres which allows it to maintain uniqueness, but only among jobs which are either waiting to start or already running. Additionally, _only the transitions between those states_ are guarded by `BEGIN...COMMIT` and it is the selection on those states that is supplemented by `SELECT ... FOR UPDATE SKIP LOCKED`. The only time locks are placed on a particular `gouda_workloads` row is when this update is about to take place (`SELECT` then `UPDATE`). This makes Gouda a good fit for use with pg_bouncer in transaction mode.
29
+
30
+ Understanding workload identity is key for making good use of Gouda. For example, an ActiveJob that gets retried can take the following shape in Gouda:
31
+
32
+ ```
33
+ ____________________________ _______________________________________________
34
+ | ActiveJob(id="0abc-...34") | ----> | Workload(id="f67b-...123",state="finished") |
35
+ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
36
+ ____________________________ _______________________________________________
37
+ | ActiveJob(id="0abc-...34") | ----> | Workload(id="5e52-...456",state="finished") |
38
+ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
39
+ ____________________________ _______________________________________________
40
+ | ActiveJob(id="0abc-...34") | ----> | Workload(id="8a41-...789",state="enqueued") |
41
+ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
42
+ ```
43
+
44
+ This would happen if, for example, the ActiveJob raises an exception inside `perform` and is configured to `retry_on` after this exception. Same for job-iteration:
45
+
46
+ ```
47
+ _______________________________________ _______________________________________________
48
+ | ActiveJob(id="0abc-...34",cursor=nil) | ----> | Workload(id="f67b-...123",state="finished") |
49
+ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
50
+ _______________________________________ _______________________________________________
51
+ | ActiveJob(id="0abc-...34",cursor=123) | ----> | Workload(id="5e52-...456",state="finished") |
52
+ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
53
+ _______________________________________ _______________________________________________
54
+ | ActiveJob(id="0abc-...34",cursor=456) | ----> | Workload(id="8a41-...789",state="executing") |
55
+ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
56
+ ```
57
+
58
+ A key thing to remember when reading the Gouda source code is that **workloads and jobs are not the same thing.** A single job **may span multiple workloads.**
59
+
60
+ ## Key concepts in Gouda: concurrency keys
61
+
62
+ Gouda has a few indexes on the `gouda_workloads` table which will:
63
+
64
+ * Forbid inserting another `enqueued` workload with the same `enqueue_concurrency_key` value. Uniqueness is on that column only.
65
+ * Forbid a workload from transition into `executing` when another workload with the same `execution_concurrency_key` is already running.
66
+
67
+ These are compatible with good_job concurrency keys, with one major distinction: we use unique indices and not counters, so these keys can be used
68
+ to **prevent concurrent executions** but not to **limit the load on the system**, and the limit of 1 is always enforced.
69
+
70
+ ## Key concepts in Gouda: `executing_on`
71
+
72
+ A `Workload` is executing on a particular `executing_on` entity - usually a worker thread. That entity gets a pseudorandom ID . The `executing_on` value can be used to see, for example, whether a particular worker thread has hung. If multiple jobs have a far-behind `updated_at` and are all `executing`, this likely means that the worker has crashed or hung. The value can also be used to build a table of currently running workers.
73
+
74
+ ## Usage tips: bulkify your enqueues
75
+
76
+ When possible, Gouda uses `enqueue_all` to `INSERT` as many jobs at once as possible. With modern servers this allows for very rapid insertion of very large
77
+ batches of jobs. It is supplemented by a module which will make all `perform_later` calls buffered and submitted to the queue in bulk:
78
+
79
+ ```ruby
80
+ Gouda.in_bulk do
81
+ User.joined_recently.find_each do |user|
82
+ WelcomeMailer.with(user:).welcome_email.deliver_later
83
+ end
84
+ end
85
+ ```
86
+
87
+ If there are multiple ActiveJob adapters configured and you bulk-enqueue a job which uses an adapter different than Gouda, `in_bulk` will try to use `enqueue_all` on that
88
+ adapter as well.
89
+
90
+ ## Usage tips: co-commit
91
+
92
+ Gouda is designed to `COMMIT` the workload together with your business data. It does not need `after_commit` unless you so choose. In fact,
93
+ the main advantage of DB-based job queues such as Gouda is that you can always rely on the fact that the workload will be enqueued only
94
+ once the data it needs to operate on is already available for reading. This is guaranteed to work:
95
+
96
+ ```ruby
97
+ User.transaction do
98
+ freshly_joined_user = User.create!(user_params)
99
+ WelcomeMailer.with(user: freshly_joined_user).welcome_email.deliver_later
100
+ end
101
+ ```
102
+
103
+ ## Web UI
15
104
 
16
105
  At the moment the Gouda UI is proprietary, so this gem only provides a "headless" implementation. We expect this to change in the future.
17
106
 
data/gouda.gemspec CHANGED
@@ -4,12 +4,12 @@ Gem::Specification.new do |spec|
4
4
  spec.name = "gouda"
5
5
  spec.version = Gouda::VERSION
6
6
  spec.summary = "Job Scheduler"
7
- spec.description = "Job Scheduler for Rails"
7
+ spec.description = "Job Scheduler for Rails and PostgreSQL"
8
8
  spec.authors = ["Sebastian van Hesteren", "Julik Tarkhanov"]
9
9
  spec.email = ["sebastian@cheddar.me", "me@julik.nl"]
10
10
  spec.homepage = "https://rubygems.org/gems/gouda"
11
11
  spec.license = "MIT"
12
- spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
13
13
  spec.require_paths = ["lib"]
14
14
 
15
15
  spec.metadata["homepage_uri"] = spec.homepage
@@ -21,7 +21,7 @@ module Gouda
21
21
  enqueue_limit = total_limit
22
22
  end
23
23
 
24
- self.gouda_concurrency_config = {perform_limit:, enqueue_limit:, key:}
24
+ self.gouda_concurrency_config = {perform_limit: perform_limit, enqueue_limit: enqueue_limit, key: key}
25
25
  end
26
26
  end
27
27
 
data/lib/gouda/railtie.rb CHANGED
@@ -34,8 +34,6 @@ module Gouda
34
34
  # The `to_prepare` block which is executed once in production
35
35
  # and before each request in development.
36
36
  config.to_prepare do
37
- Gouda::Scheduler.update_schedule_from_config!
38
-
39
37
  if defined?(Rails) && Rails.respond_to?(:application)
40
38
  config_from_rails = Rails.application.config.try(:gouda)
41
39
  if config_from_rails
@@ -52,6 +50,9 @@ module Gouda
52
50
  Gouda.config.polling_sleep_interval_seconds = 0.2
53
51
  Gouda.config.logger.level = Gouda.config.log_level
54
52
  end
53
+
54
+ Gouda::Scheduler.build_scheduler_entries_list!
55
+ Gouda::Scheduler.upsert_workloads_from_entries_list!
55
56
  end
56
57
  end
57
58
  end
@@ -53,7 +53,33 @@ module Gouda::Scheduler
53
53
  end
54
54
  end
55
55
 
56
- def self.update_schedule_from_config!(cron_table_hash = nil)
56
+ # Takes in a Hash formatted with cron entries in the format similar
57
+ # to good_job, and builds a table of scheduler entries. A scheduler
58
+ # entry references a particular job class name, the set of arguments to
59
+ # be passed to the job when performing it, and either the interval
60
+ # to repeat the job after or a cron pattern. This method does not
61
+ # insert the actual Workloads into the database but just builds the
62
+ # table of the entries. That table gets consulted when workloads finish
63
+ # to determine whether the workload that just ran was scheduled or ad-hoc,
64
+ # and whether the subsequent workload has to be enqueued.
65
+ #
66
+ # If no table is given the method will attempt to read the table from
67
+ # Rails application config from `[:gouda][:cron]`.
68
+ #
69
+ # The table is a Hash of entries, and the keys are the names of the workload
70
+ # to be enqueued - those keys are also used to ensure scheduled workloads
71
+ # only get scheduled once.
72
+ #
73
+ # @param cron_table_hash[Hash] a hash of the following shape:
74
+ # {
75
+ # download_invoices_every_minute: {
76
+ # cron: "* * * * *",
77
+ # class: "DownloadInvoicesJob",
78
+ # args: ["immediate"]
79
+ # }
80
+ # }
81
+ # @return Array[Entry]
82
+ def self.build_scheduler_entries_list!(cron_table_hash = nil)
57
83
  Gouda.logger.info "Updating scheduled workload entries..."
58
84
  if cron_table_hash.blank?
59
85
  config_from_rails = Rails.application.config.try(:gouda)
@@ -72,10 +98,16 @@ module Gouda::Scheduler
72
98
  # `class` is a reserved keyword and a method that exists on every Ruby object so...
73
99
  cron_entry_params[:job_class] ||= cron_entry_params.delete(:class)
74
100
  params_with_defaults = defaults.merge(cron_entry_params)
75
- Entry.new(name:, **params_with_defaults)
101
+ Entry.new(name: name, **params_with_defaults)
76
102
  end
77
103
  end
78
104
 
105
+ # Once a workload has finished (doesn't matter whether it raised an exception
106
+ # or completed successfully), it is going to be passed to this method to enqueue
107
+ # the next scheduled workload
108
+ #
109
+ # @param finished_workload[Gouda::Workload]
110
+ # @return void
79
111
  def self.enqueue_next_scheduled_workload_for(finished_workload)
80
112
  return unless finished_workload.scheduler_key
81
113
 
@@ -86,11 +118,23 @@ module Gouda::Scheduler
86
118
  Gouda.enqueue_jobs_via_their_adapters([timer_entry.build_active_job])
87
119
  end
88
120
 
121
+ # Returns the list of entries of the scheduler which are currently known. Normally the
122
+ # scheduler will hold the list of entries loaded from the Rails config.
123
+ #
124
+ # @return Array[Entry]
89
125
  def self.entries
90
126
  @cron_table || []
91
127
  end
92
128
 
93
- def self.update_scheduled_workloads!
129
+ # Will upsert (`INSERT ... ON CONFLICT UPDATE`) workloads for all entries which are in the scheduler entries
130
+ # table (the table needs to be read or hydrated first using `build_scheduler_entries_list!`). This is done
131
+ # in a transaction. Any workloads which have been previously inserted from the scheduled entries, but no
132
+ # longer have a corresponding scheduler entry, will be deleted from the database. If there already are workloads
133
+ # with the corresponding scheduler key they will not be touched and will be performed with their previously-defined
134
+ # arguments.
135
+ #
136
+ # @return void
137
+ def self.upsert_workloads_from_entries_list!
94
138
  table_entries = @cron_table || []
95
139
 
96
140
  # Remove any cron keyed workloads which no longer match config-wise
data/lib/gouda/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gouda
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/gouda/worker.rb CHANGED
@@ -135,7 +135,7 @@ module Gouda
135
135
  break if check_shutdown.call
136
136
 
137
137
  did_process = Gouda.config.app_executor.wrap do
138
- Gouda::Workload.checkout_and_perform_one(executing_on: worker_id_and_thread_id, queue_constraint:, in_progress: executing_workload_ids)
138
+ Gouda::Workload.checkout_and_perform_one(executing_on: worker_id_and_thread_id, queue_constraint: queue_constraint, in_progress: executing_workload_ids)
139
139
  end
140
140
 
141
141
  # If no job was retrieved the queue is likely empty. Relax the polling then and ease off.
@@ -66,7 +66,7 @@ class Gouda::Workload < ActiveRecord::Base
66
66
  # Appsignal.increment_counter("gouda_workloads_revived", 1, job_class: workload.active_job_class_name)
67
67
 
68
68
  interrupted_at = workload.last_execution_heartbeat_at
69
- workload.update!(state: "finished", interrupted_at:, last_execution_heartbeat_at: Time.now.utc, execution_finished_at: Time.now.utc)
69
+ workload.update!(state: "finished", interrupted_at: interrupted_at, last_execution_heartbeat_at: Time.now.utc, execution_finished_at: Time.now.utc)
70
70
  revived_job = ActiveJob::Base.deserialize(workload.active_job_data)
71
71
  # Save the interrupted_at timestamp so that upon execution the new job will raise a Gouda::Interrpupted exception.
72
72
  # The exception can then be handled like any other ActiveJob exception (using rescue_from or similar).
@@ -116,7 +116,7 @@ class Gouda::Workload < ActiveRecord::Base
116
116
  uncached do # Necessary because we SELECT with a clock_timestamp() which otherwise gets cached by ActiveRecord query cache
117
117
  transaction do
118
118
  jobs.first.tap do |job|
119
- job&.update!(state: "executing", executing_on:, last_execution_heartbeat_at: Time.now.utc, execution_started_at: Time.now.utc)
119
+ job&.update!(state: "executing", executing_on: executing_on, last_execution_heartbeat_at: Time.now.utc, execution_started_at: Time.now.utc)
120
120
  end
121
121
  rescue ActiveRecord::RecordNotUnique
122
122
  # It can happen that due to a race the `execution_concurrency_key NOT IN` does not capture
@@ -133,7 +133,7 @@ class Gouda::Workload < ActiveRecord::Base
133
133
  # @param in_progress[#add,#delete] Used for tracking work in progress for heartbeats
134
134
  def self.checkout_and_perform_one(executing_on:, queue_constraint: Gouda::AnyQueue, in_progress: Set.new)
135
135
  # Select a job and mark it as "executing" which will make it unavailable to any other
136
- workload = checkout_and_lock_one(executing_on:, queue_constraint:)
136
+ workload = checkout_and_lock_one(executing_on: executing_on, queue_constraint: queue_constraint)
137
137
  if workload
138
138
  in_progress.add(workload.id)
139
139
  workload.perform_and_update_state!
data/lib/gouda.rb CHANGED
@@ -46,7 +46,7 @@ module Gouda
46
46
  end
47
47
 
48
48
  def self.start
49
- Gouda::Scheduler.update_scheduled_workloads!
49
+ Gouda::Scheduler.upsert_workloads_from_entries_list!
50
50
 
51
51
  queue_constraint = if ENV["GOUDA_QUEUES"]
52
52
  Gouda.parse_queue_constraint(ENV["GOUDA_QUEUES"])
@@ -57,7 +57,7 @@ module Gouda
57
57
  Gouda.logger.info("Gouda version: #{Gouda::VERSION}")
58
58
  Gouda.logger.info("Worker threads: #{Gouda.config.worker_thread_count}")
59
59
 
60
- Gouda.worker_loop(n_threads: Gouda.config.worker_thread_count, queue_constraint:)
60
+ Gouda.worker_loop(n_threads: Gouda.config.worker_thread_count, queue_constraint: queue_constraint)
61
61
  end
62
62
 
63
63
  def self.config
data/tmp/.keep ADDED
File without changes
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gouda
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian van Hesteren
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2024-06-10 00:00:00.000000000 Z
12
+ date: 2024-06-11 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -123,7 +123,7 @@ dependencies:
123
123
  - - ">="
124
124
  - !ruby/object:Gem::Version
125
125
  version: '0'
126
- description: Job Scheduler for Rails
126
+ description: Job Scheduler for Rails and PostgreSQL
127
127
  email:
128
128
  - sebastian@cheddar.me
129
129
  - me@julik.nl
@@ -157,6 +157,7 @@ files:
157
157
  - lib/gouda/version.rb
158
158
  - lib/gouda/worker.rb
159
159
  - lib/gouda/workload.rb
160
+ - tmp/.keep
160
161
  homepage: https://rubygems.org/gems/gouda
161
162
  licenses:
162
163
  - MIT
@@ -172,14 +173,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
172
173
  requirements:
173
174
  - - ">="
174
175
  - !ruby/object:Gem::Version
175
- version: 2.4.0
176
+ version: 2.7.0
176
177
  required_rubygems_version: !ruby/object:Gem::Requirement
177
178
  requirements:
178
179
  - - ">="
179
180
  - !ruby/object:Gem::Version
180
181
  version: '0'
181
182
  requirements: []
182
- rubygems_version: 3.4.10
183
+ rubygems_version: 3.5.9
183
184
  signing_key:
184
185
  specification_version: 4
185
186
  summary: Job Scheduler