gouda 0.1.4 → 0.1.7
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +9 -6
- data/gouda.gemspec +3 -3
- data/lib/active_job/queue_adapters/gouda_adapter.rb +7 -5
- data/lib/gouda/adapter.rb +3 -2
- data/lib/gouda/bulk.rb +16 -0
- data/lib/gouda/queue_constraints.rb +25 -21
- data/lib/gouda/railtie.rb +3 -1
- data/lib/gouda/scheduler.rb +6 -0
- data/lib/gouda/version.rb +1 -1
- data/lib/gouda/worker.rb +2 -1
- data/lib/gouda/workload.rb +2 -2
- data/lib/gouda.rb +5 -2
- data/test/gouda/gouda_test.rb +9 -0
- data/test/gouda/scheduler_test.rb +23 -2
- data/test/gouda/test_helper.rb +13 -0
- metadata +5 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ec67da4c5d0dc236c02b65c770e87fe4a0a967eef8df50f0cb4ae452077f3bdb
|
4
|
+
data.tar.gz: 6089a03c99a944fcfedbe623810c72b513c52e2fe9afd698904ccab1f7cc3aa3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bb445a3c45a28d47f29124775f8f2f8c2cc9be8c6fb1069c8c3f1d00eca8269ca53d897c2cb069962bec90ee6b8b9916af133558b0692a3a64d46e3666fbb6f4
|
7
|
+
data.tar.gz: 3e82a95cf5f6107a8f9f7874a93d4bbbc58ac9a6badb2f684edca870cf6fbd3512be37299b192dcbb7a68a4be9fb638ea89af88625ae70459f1334a03b541407
|
data/CHANGELOG.md
CHANGED
@@ -23,3 +23,16 @@
|
|
23
23
|
- Reduce logging in local test runs.
|
24
24
|
- Bump local ruby version to 3.3.3
|
25
25
|
|
26
|
+
## [0.1.5] - 2023-06-18
|
27
|
+
|
28
|
+
- Update documentation
|
29
|
+
- Don't pass on scheduler keys to retries
|
30
|
+
|
31
|
+
## [0.1.6] - 2023-06-18
|
32
|
+
|
33
|
+
- Fix: don't upsert workloads twice when starting Gouda.
|
34
|
+
- Add back in Appsignal calls
|
35
|
+
|
36
|
+
## [0.1.7] - 2023-06-21
|
37
|
+
|
38
|
+
- Separate all instrumentation to use ActiveSupport::Notification
|
data/README.md
CHANGED
@@ -1,7 +1,10 @@
|
|
1
1
|
Gouda is an ActiveJob adapter used at Cheddar. It requires PostgreSQL and a recent version of Rails.
|
2
2
|
|
3
|
-
|
4
|
-
|
3
|
+
> [!CAUTION]
|
4
|
+
> At the moment Gouda is only used internally at Cheddar. Any support to external parties is on best-effort
|
5
|
+
> basis. While we are happy to see issues and pull requests, we can't guarantee that those will be addressed
|
6
|
+
> quickly. The library does receive rapid updates which may break your application if you come to depend on
|
7
|
+
> the library. That is to be expected.
|
5
8
|
|
6
9
|
## Installation
|
7
10
|
|
@@ -11,10 +14,10 @@ $ bundle install
|
|
11
14
|
$ bin/rails g gouda:install
|
12
15
|
```
|
13
16
|
|
14
|
-
Gouda is
|
15
|
-
It
|
16
|
-
|
17
|
-
|
17
|
+
Gouda is a lightweight alternative to [good_job](https://github.com/bensheldon/good_job) and [solid_queue.](https://github.com/rails/solid_queue/) - while
|
18
|
+
more similar to the latter. It has been created prior to solid_queue and is smaller. It was designed to enable job processing using `SELECT ... FOR UPDATE SKIP LOCKED`
|
19
|
+
on Postgres so that we could use pg_bouncer in our system setup. We have also observed that `SKIP LOCKED` causes less load on our database than advisory locking,
|
20
|
+
especially as queue depths would grow.
|
18
21
|
|
19
22
|
|
20
23
|
## Key concepts in Gouda: Workload
|
data/gouda.gemspec
CHANGED
@@ -7,13 +7,13 @@ Gem::Specification.new do |spec|
|
|
7
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
|
-
spec.homepage = "https://
|
10
|
+
spec.homepage = "https://github.com/cheddar-me/gouda"
|
11
11
|
spec.license = "MIT"
|
12
12
|
spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
|
13
13
|
spec.require_paths = ["lib"]
|
14
14
|
|
15
|
-
spec.metadata["homepage_uri"] =
|
16
|
-
spec.metadata["source_code_uri"] =
|
15
|
+
spec.metadata["homepage_uri"] =
|
16
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
17
17
|
spec.metadata["changelog_uri"] = "https://github.com/cheddar-me/gouda/CHANGELOG.md"
|
18
18
|
|
19
19
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
@@ -1,8 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
3
|
+
# The sole purpose of this class is so that you can do
|
4
|
+
# `config.active_job.queue_adapter = :gouda` in your Rails
|
5
|
+
# config, as Rails insists on resolving the adapter module
|
6
|
+
# name from the symbol automatically. If Rails ever allows
|
7
|
+
# us to "register" an adapter to a symbol this module can
|
8
|
+
# be removed later.
|
9
|
+
class ActiveJob::QueueAdapters::GoudaAdapter < Gouda::Adapter
|
8
10
|
end
|
data/lib/gouda/adapter.rb
CHANGED
@@ -57,10 +57,11 @@ class Gouda::Adapter
|
|
57
57
|
# We can't tell Postgres to ignore conflicts on _both_ the scheduler key and the enqueue concurrency key but not on
|
58
58
|
# the ID - it is either "all indexes" or "just one", but never "this index and that index". MERGE https://www.postgresql.org/docs/current/sql-merge.html
|
59
59
|
# is in theory capable of solving this but let's not complicate things all to hastily, the hour is getting late
|
60
|
+
scheduler_key = active_job.try(:executions) == 0 ? active_job.scheduler_key : nil # only enforce scheduler key on first workload
|
60
61
|
{
|
61
62
|
active_job_id: active_job.job_id, # Multiple jobs can have the same ID due to retries, job-iteration etc.
|
62
63
|
scheduled_at: active_job.scheduled_at || t_now,
|
63
|
-
scheduler_key:
|
64
|
+
scheduler_key: scheduler_key,
|
64
65
|
priority: active_job.priority,
|
65
66
|
execution_concurrency_key: extract_execution_concurrency_key(active_job),
|
66
67
|
enqueue_concurrency_key: extract_enqueue_concurrency_key(active_job),
|
@@ -82,7 +83,7 @@ class Gouda::Adapter
|
|
82
83
|
# Use batches of 500 so that we do not exceed the maximum statement size or do not create a transaction for the
|
83
84
|
# insert which times out
|
84
85
|
inserted_ids_and_positions = bulk_insert_attributes.each_slice(500).flat_map do |chunk|
|
85
|
-
|
86
|
+
Gouda.instrument(:insert_all, n_rows: chunk.size) do |payload|
|
86
87
|
rows = Gouda::Workload.insert_all(chunk, returning: [:id, :position_in_bulk])
|
87
88
|
payload[:inserted_jobs] = rows.length
|
88
89
|
payload[:rejected_jobs] = chunk.size - rows.length
|
data/lib/gouda/bulk.rb
CHANGED
@@ -1,6 +1,22 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Gouda
|
4
|
+
# Inside this call, all `perform_later` calls on ActiveJob subclasses
|
5
|
+
# (including mailers) will be buffered. The call is reentrant, so you
|
6
|
+
# can have multiple `in_bulk` calls with arbitrary nesting. At the end
|
7
|
+
# of the block, the buffered jobs will be enqueued using their respective
|
8
|
+
# adapters. If an adapter supports `enqueue_all` (Sidekiq does in recent
|
9
|
+
# releases of Rails, for example), this functionality will be used. This
|
10
|
+
# method is especially useful when doing things such as mass-emails, or
|
11
|
+
# maintenance tasks where a large swath of jobs gets enqueued at once.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# Gouda.in_bulk do
|
15
|
+
# User.recently_joined.find_each do |recently_joined_user|
|
16
|
+
# GreetingJob.perform_later(recently_joined_user)
|
17
|
+
# end
|
18
|
+
# end
|
19
|
+
# @return [Object] the return value of the block
|
4
20
|
def self.in_bulk(&blk)
|
5
21
|
if Thread.current[:gouda_bulk_buffer].nil?
|
6
22
|
Thread.current[:gouda_bulk_buffer] = []
|
@@ -1,44 +1,48 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Gouda
|
4
|
+
# A queue constraint supports just one method, `to_sql`, which returns
|
5
|
+
# a condition based on the `queue_name` value of the `gouda_workloads`
|
6
|
+
# table. The minimal constraint is just a no-op - it allows execution
|
7
|
+
# of jobs from all queues in the system.
|
4
8
|
module AnyQueue
|
5
9
|
def self.to_sql
|
6
10
|
"1=1"
|
7
11
|
end
|
8
12
|
end
|
9
13
|
|
14
|
+
# Allows execution of jobs only from specified queues
|
15
|
+
# For example, if you have a queue named "gpu", and you run
|
16
|
+
# jobs requiring a GPU on this queue, on your worker script
|
17
|
+
# running on GPU-equipped machines you could use
|
18
|
+
# `OnlyQueuesConstraint.new([:gpu])`
|
10
19
|
class OnlyQueuesConstraint < Struct.new(:queue_names)
|
11
20
|
def to_sql
|
12
21
|
placeholders = (["?"] * queue_names.length).join(",")
|
13
|
-
|
22
|
+
Gouda::Workload.sanitize_sql_array([<<~SQL, *queue_names])
|
14
23
|
queue_name IN (#{placeholders})
|
15
24
|
SQL
|
16
25
|
end
|
17
26
|
end
|
18
27
|
|
28
|
+
# Allows execution of jobs from queues except the given ones
|
29
|
+
# For example, if you have a queue named "emails" which is time-critical,
|
30
|
+
# on all other machines your worker script can specify
|
31
|
+
# `ExceptQueueConstraint.new([:emails])`
|
19
32
|
class ExceptQueueConstraint < Struct.new(:queue_names)
|
20
33
|
def to_sql
|
21
34
|
placeholders = (["?"] * queue_names.length).join(",")
|
22
|
-
|
35
|
+
Gouda::Workload.sanitize_sql_array([<<~SQL, *queue_names])
|
23
36
|
queue_name NOT IN (#{placeholders})
|
24
37
|
SQL
|
25
38
|
end
|
26
39
|
end
|
27
40
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
ExceptQueueConstraint.new(parsed[:exclude])
|
34
|
-
else
|
35
|
-
AnyQueue
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
|
-
# Parse a string representing a group of queues into a more readable data
|
40
|
-
# structure.
|
41
|
-
# @param string [String] Queue string
|
41
|
+
# Parse a string representing a group of queues into a queue constraint
|
42
|
+
# Note that this works similar to good_job. For example, the
|
43
|
+
# constraints do not necessarily compose all that well.
|
44
|
+
#
|
45
|
+
# @param queue_constraint_str[String] Queue string
|
42
46
|
# @return [Hash]
|
43
47
|
# How to match a given queue. It can have the following keys and values:
|
44
48
|
# - +{ all: true }+ indicates that all queues match.
|
@@ -49,8 +53,8 @@ module Gouda
|
|
49
53
|
# @example
|
50
54
|
# Gouda::QueueConstraints.queue_parser('-queue1,queue2')
|
51
55
|
# => { exclude: [ 'queue1', 'queue2' ] }
|
52
|
-
def self.
|
53
|
-
string =
|
56
|
+
def self.parse_queue_constraint(queue_constraint_str)
|
57
|
+
string = queue_constraint_str.presence || "*"
|
54
58
|
|
55
59
|
case string.first
|
56
60
|
when "-"
|
@@ -63,11 +67,11 @@ module Gouda
|
|
63
67
|
queues = string.split(",").map(&:strip)
|
64
68
|
|
65
69
|
if queues.include?("*")
|
66
|
-
|
70
|
+
AnyQueue
|
67
71
|
elsif exclude_queues
|
68
|
-
|
72
|
+
ExceptQueueConstraint.new([queues])
|
69
73
|
else
|
70
|
-
|
74
|
+
OnlyQueuesConstraint.new([queues])
|
71
75
|
end
|
72
76
|
end
|
73
77
|
end
|
data/lib/gouda/railtie.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Gouda
|
4
|
+
UNINITIALISED_DATABASE_EXCEPTIONS = [ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished]
|
5
|
+
|
4
6
|
class Railtie < Rails::Railtie
|
5
7
|
rake_tasks do
|
6
8
|
task preload: :setup do
|
@@ -54,7 +56,7 @@ module Gouda
|
|
54
56
|
Gouda::Scheduler.build_scheduler_entries_list!
|
55
57
|
begin
|
56
58
|
Gouda::Scheduler.upsert_workloads_from_entries_list!
|
57
|
-
rescue
|
59
|
+
rescue *Gouda::UNINITIALISED_DATABASE_EXCEPTIONS
|
58
60
|
# Do nothing. On a freshly checked-out Rails app, running even unrelated Rails tasks
|
59
61
|
# (such as asset compilation) - or, more importantly, initial db:create -
|
60
62
|
# will cause a NoDatabaseError, as this is a chicken-and-egg problem. That error
|
data/lib/gouda/scheduler.rb
CHANGED
@@ -11,6 +11,9 @@ module Gouda::Scheduler
|
|
11
11
|
[name, interval_seconds, cron, job_class].compact.join("_")
|
12
12
|
end
|
13
13
|
|
14
|
+
# Tells when this particular task should next run
|
15
|
+
#
|
16
|
+
# @return [Time]
|
14
17
|
def next_at
|
15
18
|
if interval_seconds
|
16
19
|
first_existing = Gouda::Workload.where(scheduler_key: scheduler_key).where("scheduled_at > NOW()").order("scheduled_at DESC").pluck(:scheduled_at).first
|
@@ -22,6 +25,9 @@ module Gouda::Scheduler
|
|
22
25
|
end
|
23
26
|
end
|
24
27
|
|
28
|
+
# Builds the ActiveJob which can be enqueued for this entry
|
29
|
+
#
|
30
|
+
# @return [ActiveJob::Base]
|
25
31
|
def build_active_job
|
26
32
|
next_at = self.next_at
|
27
33
|
return unless next_at
|
data/lib/gouda/version.rb
CHANGED
data/lib/gouda/worker.rb
CHANGED
@@ -163,7 +163,8 @@ module Gouda
|
|
163
163
|
# Find jobs which just hung and clean them up (mark them as "finished" and enqueue replacement workloads if possible)
|
164
164
|
Gouda::Workload.reap_zombie_workloads
|
165
165
|
rescue => e
|
166
|
-
|
166
|
+
Gouda.instrument(:exception, exception: e)
|
167
|
+
|
167
168
|
warn "Uncaught exception during housekeeping (#{e.class} - #{e}"
|
168
169
|
end
|
169
170
|
|
data/lib/gouda/workload.rb
CHANGED
@@ -63,7 +63,7 @@ class Gouda::Workload < ActiveRecord::Base
|
|
63
63
|
workload.with_lock("FOR UPDATE SKIP LOCKED") do
|
64
64
|
Gouda.logger.info { "Reviving (re-enqueueing) Gouda workload #{workload.id} after interruption" }
|
65
65
|
|
66
|
-
|
66
|
+
Gouda.instrument(:workloads_revived_counter, size: 1, job_class: workload.active_job_class_name)
|
67
67
|
|
68
68
|
interrupted_at = workload.last_execution_heartbeat_at
|
69
69
|
workload.update!(state: "finished", interrupted_at: interrupted_at, last_execution_heartbeat_at: Time.now.utc, execution_finished_at: Time.now.utc)
|
@@ -110,7 +110,7 @@ class Gouda::Workload < ActiveRecord::Base
|
|
110
110
|
.lock("FOR UPDATE SKIP LOCKED")
|
111
111
|
.limit(1)
|
112
112
|
|
113
|
-
_first_available_workload =
|
113
|
+
_first_available_workload = Gouda.instrument(:checkout_and_lock_one, queue_constraint: queue_constraint.to_sql) do |payload|
|
114
114
|
payload[:condition_sql] = jobs.to_sql
|
115
115
|
payload[:retried_checkouts_due_to_concurrent_exec] = 0
|
116
116
|
uncached do # Necessary because we SELECT with a clock_timestamp() which otherwise gets cached by ActiveRecord query cache
|
data/lib/gouda.rb
CHANGED
@@ -46,8 +46,6 @@ module Gouda
|
|
46
46
|
end
|
47
47
|
|
48
48
|
def self.start
|
49
|
-
Gouda::Scheduler.upsert_workloads_from_entries_list!
|
50
|
-
|
51
49
|
queue_constraint = if ENV["GOUDA_QUEUES"]
|
52
50
|
Gouda.parse_queue_constraint(ENV["GOUDA_QUEUES"])
|
53
51
|
else
|
@@ -72,6 +70,11 @@ module Gouda
|
|
72
70
|
Gouda.config.logger
|
73
71
|
end
|
74
72
|
|
73
|
+
def self.instrument(channel, **options, &block)
|
74
|
+
ActiveSupport::Notifications.instrument("#{channel}.gouda", **options, &block)
|
75
|
+
end
|
76
|
+
|
77
|
+
|
75
78
|
def self.create_tables(active_record_schema)
|
76
79
|
active_record_schema.create_enum :gouda_workload_state, %w[enqueued executing finished]
|
77
80
|
active_record_schema.create_table :gouda_workloads, id: :uuid do |t|
|
data/test/gouda/gouda_test.rb
CHANGED
@@ -645,6 +645,15 @@ class GoudaTest < ActiveSupport::TestCase
|
|
645
645
|
assert_equal ["did-run"], Thread.current[:gouda_test_side_effects]
|
646
646
|
end
|
647
647
|
|
648
|
+
test "instrumentation" do
|
649
|
+
payload = subscribed_notification_for("workloads_revived_counter.gouda") do
|
650
|
+
Gouda.instrument(:workloads_revived_counter, size: 1, job_class: "test_class")
|
651
|
+
end
|
652
|
+
|
653
|
+
assert_equal "test_class", payload[:job_class]
|
654
|
+
assert_equal 1, payload[:size]
|
655
|
+
end
|
656
|
+
|
648
657
|
def sample_description(sample)
|
649
658
|
values = sample.map(&:to_f).sort
|
650
659
|
|
@@ -24,8 +24,6 @@ class GoudaSchedulerTest < ActiveSupport::TestCase
|
|
24
24
|
class MegaError < StandardError
|
25
25
|
end
|
26
26
|
|
27
|
-
gouda_control_concurrency_with(enqueue_limit: 1, key: -> { self.class.to_s })
|
28
|
-
|
29
27
|
retry_on StandardError, wait: :polynomially_longer, attempts: 5
|
30
28
|
retry_on Gouda::InterruptError, wait: 0, attempts: 5
|
31
29
|
retry_on MegaError, attempts: 3, wait: 0
|
@@ -55,6 +53,29 @@ class GoudaSchedulerTest < ActiveSupport::TestCase
|
|
55
53
|
assert Gouda::Workload.count > 3
|
56
54
|
end
|
57
55
|
|
56
|
+
test "retries do not have a scheduler_key" do
|
57
|
+
tab = {
|
58
|
+
second_minutely: {
|
59
|
+
cron: "*/1 * * * * *", # every second
|
60
|
+
class: "GoudaSchedulerTest::FailingJob"
|
61
|
+
}
|
62
|
+
}
|
63
|
+
|
64
|
+
assert_nothing_raised do
|
65
|
+
Gouda::Scheduler.build_scheduler_entries_list!(tab)
|
66
|
+
Gouda::Scheduler.upsert_workloads_from_entries_list!
|
67
|
+
end
|
68
|
+
|
69
|
+
assert_equal 1, Gouda::Workload.enqueued.count
|
70
|
+
assert_equal "second_minutely_*/1 * * * * *_GoudaSchedulerTest::FailingJob", Gouda::Workload.enqueued.first.scheduler_key
|
71
|
+
sleep(2)
|
72
|
+
Gouda::Workload.checkout_and_perform_one(executing_on: "Unit test")
|
73
|
+
|
74
|
+
assert_equal 1, Gouda::Workload.retried.reload.count
|
75
|
+
assert_nil Gouda::Workload.retried.first.scheduler_key
|
76
|
+
assert_equal "enqueued", Gouda::Workload.retried.first.state
|
77
|
+
end
|
78
|
+
|
58
79
|
test "re-inserts the next subsequent job after executing the queued one" do
|
59
80
|
tab = {
|
60
81
|
second_minutely: {
|
data/test/gouda/test_helper.rb
CHANGED
@@ -67,4 +67,17 @@ class ActiveSupport::TestCase
|
|
67
67
|
end
|
68
68
|
end
|
69
69
|
end
|
70
|
+
|
71
|
+
def subscribed_notification_for(notification)
|
72
|
+
payload = nil
|
73
|
+
subscription = ActiveSupport::Notifications.subscribe notification do |name, start, finish, id, _payload|
|
74
|
+
payload = _payload
|
75
|
+
end
|
76
|
+
|
77
|
+
yield
|
78
|
+
|
79
|
+
ActiveSupport::Notifications.unsubscribe(subscription)
|
80
|
+
|
81
|
+
return payload
|
82
|
+
end
|
70
83
|
end
|
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.
|
4
|
+
version: 0.1.7
|
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-
|
12
|
+
date: 2024-06-21 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activerecord
|
@@ -166,12 +166,12 @@ files:
|
|
166
166
|
- test/gouda/workload_test.rb
|
167
167
|
- test/support/assert_helper.rb
|
168
168
|
- tmp/.keep
|
169
|
-
homepage: https://
|
169
|
+
homepage: https://github.com/cheddar-me/gouda
|
170
170
|
licenses:
|
171
171
|
- MIT
|
172
172
|
metadata:
|
173
|
-
homepage_uri: https://rubygems.org/gems/gouda
|
174
173
|
source_code_uri: https://github.com/cheddar-me/gouda
|
174
|
+
homepage_uri: https://github.com/cheddar-me/gouda
|
175
175
|
changelog_uri: https://github.com/cheddar-me/gouda/CHANGELOG.md
|
176
176
|
post_install_message:
|
177
177
|
rdoc_options: []
|
@@ -188,7 +188,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
188
188
|
- !ruby/object:Gem::Version
|
189
189
|
version: '0'
|
190
190
|
requirements: []
|
191
|
-
rubygems_version: 3.5.
|
191
|
+
rubygems_version: 3.5.13
|
192
192
|
signing_key:
|
193
193
|
specification_version: 4
|
194
194
|
summary: Job Scheduler
|