sidekiq-amigo 1.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.
- checksums.yaml +7 -0
- data/lib/amigo/audit_logger.rb +16 -0
- data/lib/amigo/deprecated_jobs.rb +41 -0
- data/lib/amigo/job.rb +242 -0
- data/lib/amigo/router.rb +18 -0
- data/lib/amigo/scheduled_job.rb +71 -0
- data/lib/amigo/spec_helpers.rb +234 -0
- data/lib/amigo/version.rb +5 -0
- data/lib/amigo.rb +272 -0
- metadata +165 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: b5204accb28834a6d5cb0baf274aa92e96f8ab2480316d6578c1e35e31075f44
|
4
|
+
data.tar.gz: c397ff5a38053be56726310f8b5e4e1b934c85c84f17fa60bb636e6d4b477252
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 04ce08ea274cf3132db350ecf826787e594faa47e1905fbc61307156d6b87f6a33b5301c05acf2b88b21aff52d600e52ae7cc31dd83f53e72ac972c5c2b56e0e
|
7
|
+
data.tar.gz: e46d7aab0d27953b1d16903cb98d77e8b8ddcbe56ea091950c76bdf60c18e3207cc085dc2c87e3f0e78c2f7e310325d4de8492c3d73f04babdcbf0a53be2a3f9
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "amigo/job"
|
4
|
+
|
5
|
+
class Amigo
|
6
|
+
class AuditLogger
|
7
|
+
include Sidekiq::Worker
|
8
|
+
|
9
|
+
def perform(event_json)
|
10
|
+
Amigo.log(self, :info, "async_job_audit",
|
11
|
+
event_id: event_json["id"],
|
12
|
+
event_name: event_json["name"],
|
13
|
+
event_payload: event_json["payload"],)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "amigo/job"
|
4
|
+
|
5
|
+
# Put jobs here to die. If you just remove a job in Sidekiq, it may be queued up
|
6
|
+
# (like if it's scheduled or retrying),
|
7
|
+
# and will fail if the class does not exist.
|
8
|
+
#
|
9
|
+
# So, make the class exist, but noop so it won't be scheduled and won't be retried.
|
10
|
+
# Then it can be deleted later.
|
11
|
+
#
|
12
|
+
class Amigo
|
13
|
+
module DeprecatedJobs
|
14
|
+
def self.install(const_base, *names)
|
15
|
+
cls = self.noop_class
|
16
|
+
names.each { |n| self.__install_one(const_base, n, cls) }
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.__install_one(const_base, cls_name, cls)
|
20
|
+
name_parts = cls_name.split("::").map(&:to_sym)
|
21
|
+
name_parts[0..-2].each do |part|
|
22
|
+
const_base = if const_base.const_defined?(part)
|
23
|
+
const_base.const_get(part)
|
24
|
+
else
|
25
|
+
const_base.const_set(part, Module.new)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
const_base.const_set(name_parts.last, cls)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.noop_class
|
32
|
+
cls = Class.new do
|
33
|
+
def _perform(*)
|
34
|
+
Amigo.log(self, :warn, "deprecated_job_invoked", nil)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
cls.extend(Amigo::Job)
|
38
|
+
return cls
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/lib/amigo/job.rb
ADDED
@@ -0,0 +1,242 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sidekiq"
|
4
|
+
|
5
|
+
class Amigo
|
6
|
+
module Job
|
7
|
+
def self.extended(cls)
|
8
|
+
Amigo.registered_jobs << cls
|
9
|
+
|
10
|
+
cls.include(Sidekiq::Worker)
|
11
|
+
cls.extend(ClassMethods)
|
12
|
+
cls.pattern = ""
|
13
|
+
cls.include(InstanceMethods)
|
14
|
+
end
|
15
|
+
|
16
|
+
module InstanceMethods
|
17
|
+
def initialize(*)
|
18
|
+
super
|
19
|
+
@change_matchers = {}
|
20
|
+
end
|
21
|
+
|
22
|
+
def logger
|
23
|
+
return Sidekiq.logger
|
24
|
+
end
|
25
|
+
|
26
|
+
def perform(*args)
|
27
|
+
if args.empty?
|
28
|
+
event = nil
|
29
|
+
elsif args.size == 1
|
30
|
+
event = Amigo::Event.from_json(args[0])
|
31
|
+
else
|
32
|
+
raise "perform should always be called with no args or [Amigo::Event#as_json], got %p" % [args]
|
33
|
+
end
|
34
|
+
self._perform(event)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Return +klass[payload_or_id]+ if it exists, or raise an error if it does not.
|
38
|
+
# +payload_or_id+ can be an integer,
|
39
|
+
# or the async job payload if the id is the first argument.
|
40
|
+
#
|
41
|
+
# Examples:
|
42
|
+
# - `customer = self.lookup_model( MyApp::Customer, event )`
|
43
|
+
# - `customer = self.lookup_model( MyApp::Customer, event.payload )`
|
44
|
+
# - `customer = self.lookup_model( MyApp::Customer, customer_id )`
|
45
|
+
def lookup_model(klass, payload_or_id)
|
46
|
+
if payload_or_id.is_a?(Integer)
|
47
|
+
id = payload_or_id
|
48
|
+
elsif payload_or_id.respond_to?(:payload)
|
49
|
+
id = payload_or_id.payload.first
|
50
|
+
elsif payload_or_id.respond_to?(:first)
|
51
|
+
id = payload_or_id.first
|
52
|
+
else
|
53
|
+
raise "Don't know how to handle #{payload_or_id}"
|
54
|
+
end
|
55
|
+
result = klass[id] or raise "%s[%p] does not exist" % [klass.name, id]
|
56
|
+
return result
|
57
|
+
end
|
58
|
+
|
59
|
+
# Create a matcher against the 'changed' hash of an update event.
|
60
|
+
#
|
61
|
+
# Example:
|
62
|
+
# on 'myapp.customer.update' do |payload, _|
|
63
|
+
# customerid, changes, _ = *payload
|
64
|
+
# customer = MyApp::Customer[ customerid ] or return false
|
65
|
+
#
|
66
|
+
# case changes
|
67
|
+
# when changed( :password )
|
68
|
+
# send_password_change_email( customer )
|
69
|
+
# when changed( :activation_code, to: nil )
|
70
|
+
# send_welcome_email( customer )
|
71
|
+
# when changed( :type, from: 'seeder', to: 'eater' )
|
72
|
+
# send_becoming_an_eater_email( customer )
|
73
|
+
# end
|
74
|
+
# end
|
75
|
+
def changed(field, values={})
|
76
|
+
unless @change_matchers[[field, values]]
|
77
|
+
match_proc = self.make_match_proc_for(field, values)
|
78
|
+
@change_matchers[[field, values]] = match_proc
|
79
|
+
end
|
80
|
+
|
81
|
+
return @change_matchers[[field, values]]
|
82
|
+
end
|
83
|
+
|
84
|
+
# Make a curried Proc for calling a match method with the given +field+ and +values+.
|
85
|
+
def make_match_proc_for(field, values)
|
86
|
+
return self.method(:match_any_change).to_proc.curry[field] if values.empty?
|
87
|
+
|
88
|
+
if values.key?(:from)
|
89
|
+
if values.key?(:to)
|
90
|
+
return self.method(:match_change_from_to).to_proc.
|
91
|
+
curry[ field, values[:from], values[:to] ]
|
92
|
+
else
|
93
|
+
return self.method(:match_change_from).to_proc.
|
94
|
+
curry[ field, values[:from] ]
|
95
|
+
end
|
96
|
+
elsif values.key?(:to)
|
97
|
+
return self.method(:match_change_to).to_proc.
|
98
|
+
curry[ field, values[:to] ]
|
99
|
+
else
|
100
|
+
raise ScriptError,
|
101
|
+
"Unhandled change option/s: %p; expected :to and/or :from" % [values.keys]
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Returns +true+ if the given +field+ is listed at all in the specified
|
106
|
+
# +changes+.
|
107
|
+
def match_any_change(field, changes)
|
108
|
+
self.logger.debug "Checking for existance of field %p in %p" % [field, changes]
|
109
|
+
return changes&.key?(field.to_s)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Returns +true+ if the given +field+ is listed in the specified +changes+,
|
113
|
+
# and the value it changed to matches +to+. The +to+ value can be:
|
114
|
+
#
|
115
|
+
# a Regexp::
|
116
|
+
# The new value is stringified, and matched against the +to+ Regexp.
|
117
|
+
# an immediate value (String, Numeric, NilClass, etc.)::
|
118
|
+
# The new value is matched using Object#==.
|
119
|
+
#
|
120
|
+
def match_change_to(field, to, changes)
|
121
|
+
self.logger.debug "Checking for change to %p of field %p in %p" % [to, field, changes]
|
122
|
+
return false unless changes&.key?(field.to_s)
|
123
|
+
|
124
|
+
newval = changes[field.to_s][1]
|
125
|
+
|
126
|
+
case to
|
127
|
+
when NilClass, Numeric, String, TrueClass, FalseClass
|
128
|
+
return newval == to
|
129
|
+
when Regexp
|
130
|
+
return to.match(newval)
|
131
|
+
when Proc
|
132
|
+
return to[newval]
|
133
|
+
else
|
134
|
+
raise TypeError, "Unhandled type of 'to' criteria %p (a %p)" % [to, to.class]
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# Returns +true+ if the given +field+ is listed in the specified +changes+,
|
139
|
+
# and the value it changed from matches +from+. The +from+ value can be:
|
140
|
+
#
|
141
|
+
# a Regexp::
|
142
|
+
# The old value is stringified, and matched against the +from+ Regexp.
|
143
|
+
# an immediate value (String, Numeric, NilClass, etc.)::
|
144
|
+
# The old value is matched using Object#==.
|
145
|
+
#
|
146
|
+
def match_change_from(field, from, changes)
|
147
|
+
self.logger.debug "Checking for change from %p of field %p in %p" % [from, field, changes]
|
148
|
+
return false unless changes&.key?(field.to_s)
|
149
|
+
|
150
|
+
oldval = changes[field.to_s][0]
|
151
|
+
|
152
|
+
case from
|
153
|
+
when NilClass, Numeric, String, TrueClass, FalseClass
|
154
|
+
return oldval == from
|
155
|
+
when Regexp
|
156
|
+
return from.match(oldval)
|
157
|
+
when Proc
|
158
|
+
return from[oldval]
|
159
|
+
else
|
160
|
+
raise TypeError, "Unhandled type of 'from' criteria %p (a %p)" % [from, from.class]
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Returns +true+ if the given +field+ is listed in the specified +changes+,
|
165
|
+
# and the value it changed from matches +from+ and the value it changed to
|
166
|
+
# matches +to+. The +from+ and +to+ values can be:
|
167
|
+
#
|
168
|
+
# a Regexp::
|
169
|
+
# The corresponding value is stringified, and matched against the Regexp.
|
170
|
+
# an immediate value (String, Numeric, NilClass, etc.)::
|
171
|
+
# The corresponding value is matched using Object#==.
|
172
|
+
#
|
173
|
+
def match_change_from_to(field, from, to, changes)
|
174
|
+
self.logger.debug "Checking for change from %p to %p of field %p in %p" %
|
175
|
+
[from, to, field, changes]
|
176
|
+
return false unless changes&.key?(field.to_s)
|
177
|
+
return self.match_change_to(field, to, changes) &&
|
178
|
+
self.match_change_from(field, from, changes)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Create a matcher against a changed Hash JSON column of an update event.
|
182
|
+
#
|
183
|
+
# Example:
|
184
|
+
# on 'myapp.customer.update' do |payload, _|
|
185
|
+
# customerid, changes, _ = *payload
|
186
|
+
#
|
187
|
+
# case changes
|
188
|
+
# when changed_at( :flags, :trustworthy, to: true )
|
189
|
+
# mark_customer_safe_in_external_service( customerid )
|
190
|
+
# end
|
191
|
+
# end
|
192
|
+
def changed_at(field, index, values={})
|
193
|
+
return self.method(:match_change_at).to_proc.curry[field, index, values]
|
194
|
+
end
|
195
|
+
|
196
|
+
# Return +true+ if `field[index]` has changed in the specified +changes+,
|
197
|
+
# configured by +values+ (contains +from+, +to+, or whatever supported kwargs).
|
198
|
+
# Unlike other matches, +changes+ is expected to be an entire +Hash+
|
199
|
+
# and may contain a value at +index+ in either or both sets of changes.
|
200
|
+
# This method does not attempt to do the matching itself.
|
201
|
+
# Rather, it sets up the data to defer to the other matcher methods.
|
202
|
+
def match_change_at(field, index, values, changes)
|
203
|
+
field = field.to_s
|
204
|
+
return false unless changes&.key?(field)
|
205
|
+
index = index.to_s
|
206
|
+
old_values, new_values = changes[field]
|
207
|
+
unrolled_changes = self.unroll_hash_changes(old_values, new_values)
|
208
|
+
return self.changed(index, values)[unrolled_changes]
|
209
|
+
end
|
210
|
+
|
211
|
+
# Given two hashes that represent the before and after column hash values,
|
212
|
+
# return a hash in the usual "changes" format,
|
213
|
+
# where keys are the hash keys with changed values,
|
214
|
+
# and values are a tuple of the before and after values.
|
215
|
+
def unroll_hash_changes(old_hash, new_hash)
|
216
|
+
old_hash ||= {}
|
217
|
+
new_hash ||= {}
|
218
|
+
return old_hash.keys.concat(new_hash.keys).uniq.each_with_object({}) do |key, h|
|
219
|
+
old_val = old_hash[key]
|
220
|
+
new_val = new_hash[key]
|
221
|
+
h[key] = [old_val, new_val] if old_val != new_val
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
module ClassMethods
|
227
|
+
attr_accessor :pattern
|
228
|
+
|
229
|
+
def on(pattern)
|
230
|
+
self.pattern = pattern
|
231
|
+
end
|
232
|
+
|
233
|
+
def scheduled_job?
|
234
|
+
return false
|
235
|
+
end
|
236
|
+
|
237
|
+
def event_job?
|
238
|
+
return true
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
data/lib/amigo/router.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "amigo/job"
|
4
|
+
|
5
|
+
class Amigo
|
6
|
+
class Router
|
7
|
+
include Sidekiq::Worker
|
8
|
+
|
9
|
+
def perform(event_json)
|
10
|
+
event_name = event_json["name"]
|
11
|
+
matches = Amigo.registered_event_jobs.
|
12
|
+
select { |job| File.fnmatch(job.pattern, event_name, File::FNM_EXTGLOB) }
|
13
|
+
matches.each do |job|
|
14
|
+
Amigo.synchronous_mode ? job.new.perform(event_json) : job.perform_async(event_json)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sidekiq"
|
4
|
+
require "sidekiq-cron"
|
5
|
+
|
6
|
+
require "amigo"
|
7
|
+
|
8
|
+
class Amigo
|
9
|
+
module ScheduledJob
|
10
|
+
def self.extended(cls)
|
11
|
+
Amigo.registered_jobs << cls
|
12
|
+
|
13
|
+
cls.include(Sidekiq::Worker)
|
14
|
+
cls.sidekiq_options(retry: false)
|
15
|
+
cls.extend(ClassMethods)
|
16
|
+
cls.splay_duration = 30
|
17
|
+
cls.include(InstanceMethods)
|
18
|
+
end
|
19
|
+
|
20
|
+
module InstanceMethods
|
21
|
+
def logger
|
22
|
+
return Sidekiq.logger
|
23
|
+
end
|
24
|
+
|
25
|
+
def perform(*args)
|
26
|
+
if args.empty?
|
27
|
+
jitter = rand(0..self.class.splay_duration.to_i)
|
28
|
+
self.class.perform_in(jitter, true)
|
29
|
+
elsif args == [true]
|
30
|
+
self._perform
|
31
|
+
else
|
32
|
+
raise "ScheduledJob#perform must be called with no arguments, or [true]"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
module ClassMethods
|
38
|
+
attr_accessor :cron_expr, :splay_duration
|
39
|
+
|
40
|
+
def scheduled_job?
|
41
|
+
return true
|
42
|
+
end
|
43
|
+
|
44
|
+
def event_job?
|
45
|
+
return false
|
46
|
+
end
|
47
|
+
|
48
|
+
# Return the UTC hour for the given hour and timezone.
|
49
|
+
# For example, during DST, `utc_hour(6, 'US/Pacific')` returns 13 (or, 6 + 7),
|
50
|
+
# while in standard time (not DST) it returns 8 (or, 6 + 8).
|
51
|
+
# This is useful in crontab notation, when we want something to happen at
|
52
|
+
# a certain local time and don't want it to shift with DST.
|
53
|
+
def utc_hour(hour, tzstr)
|
54
|
+
local = TZInfo::Timezone.get(tzstr)
|
55
|
+
utc = TZInfo::Timezone.get("UTC")
|
56
|
+
n = Time.now
|
57
|
+
intz = Time.new(n.year, n.month, n.day, hour, n.min, n.sec, local)
|
58
|
+
inutc = utc.to_local(intz)
|
59
|
+
return inutc.hour
|
60
|
+
end
|
61
|
+
|
62
|
+
def cron(expr)
|
63
|
+
self.cron_expr = expr
|
64
|
+
end
|
65
|
+
|
66
|
+
def splay(duration)
|
67
|
+
self.splay_duration = duration
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,234 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sidekiq/testing"
|
4
|
+
|
5
|
+
class Amigo
|
6
|
+
module SpecHelpers
|
7
|
+
def self.included(context)
|
8
|
+
Sidekiq::Testing.inline!
|
9
|
+
context.before(:each) do |example|
|
10
|
+
Amigo.synchronous_mode = true if example.metadata[:async]
|
11
|
+
end
|
12
|
+
context.after(:each) do |example|
|
13
|
+
Amigo.synchronous_mode = false if example.metadata[:async]
|
14
|
+
end
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
module_function def snapshot_async_state(opts={})
|
19
|
+
old_hooks = Amigo.subscribers.to_a
|
20
|
+
old_jobs = Amigo.registered_jobs.to_a
|
21
|
+
old_failure = Amigo.on_publish_error
|
22
|
+
|
23
|
+
begin
|
24
|
+
Amigo.on_publish_error = opts[:on_error] if opts.key?(:on_error)
|
25
|
+
Amigo.subscribers.replace(opts[:subscribers]) if opts.key?(:subscribers)
|
26
|
+
Amigo.registered_jobs.replace(opts[:jobs]) if opts.key?(:jobs)
|
27
|
+
yield
|
28
|
+
ensure
|
29
|
+
Amigo.on_publish_error = old_failure
|
30
|
+
Amigo.subscribers.replace(old_hooks)
|
31
|
+
Amigo.registered_jobs.replace(old_jobs)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class EventPublishedMatcher
|
36
|
+
attr_reader :recorded_events
|
37
|
+
|
38
|
+
def initialize(eventname, expected_payload=[])
|
39
|
+
@expected_events = [[eventname, expected_payload]]
|
40
|
+
@recorded_events = []
|
41
|
+
@missing = []
|
42
|
+
@matched = []
|
43
|
+
end
|
44
|
+
|
45
|
+
def and(another_eventname, *expected_payload)
|
46
|
+
@expected_events << [another_eventname, expected_payload]
|
47
|
+
return self
|
48
|
+
end
|
49
|
+
|
50
|
+
def with_payload(expected_payload)
|
51
|
+
raise ArgumentError, "expected payload must be an array or matcher" unless
|
52
|
+
expected_payload.is_a?(Array) || expected_payload.respond_to?(:matches?)
|
53
|
+
@expected_events.last[1] = expected_payload
|
54
|
+
return self
|
55
|
+
end
|
56
|
+
|
57
|
+
def record_event(event)
|
58
|
+
Amigo.log nil, :debug, "recording_event", event: event
|
59
|
+
@recorded_events << event
|
60
|
+
end
|
61
|
+
|
62
|
+
def supports_block_expectations?
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
def matches?(given_proc)
|
67
|
+
unless given_proc.respond_to?(:call)
|
68
|
+
warn "publish matcher used with non-proc object #{given_proc.inspect}"
|
69
|
+
return false
|
70
|
+
end
|
71
|
+
|
72
|
+
unless Amigo.synchronous_mode
|
73
|
+
warn "publish matcher used without synchronous_mode (use :async test metadata)"
|
74
|
+
return false
|
75
|
+
end
|
76
|
+
|
77
|
+
state = {on_error: self.method(:on_publish_error), subscribers: [self.method(:record_event)]}
|
78
|
+
Amigo::SpecHelpers.snapshot_async_state(state) do
|
79
|
+
given_proc.call
|
80
|
+
end
|
81
|
+
|
82
|
+
self.match_expected_events
|
83
|
+
|
84
|
+
return @error.nil? && @missing.empty?
|
85
|
+
end
|
86
|
+
|
87
|
+
def on_publish_error(err)
|
88
|
+
@error = err
|
89
|
+
end
|
90
|
+
|
91
|
+
def match_expected_events
|
92
|
+
@expected_events.each do |expected_event, expected_payload|
|
93
|
+
match = @recorded_events.find do |recorded|
|
94
|
+
recorded.name == expected_event && self.payloads_match?(expected_payload, recorded.payload)
|
95
|
+
end
|
96
|
+
|
97
|
+
if match
|
98
|
+
self.add_matched(expected_event, expected_payload)
|
99
|
+
else
|
100
|
+
self.add_missing(expected_event, expected_payload)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def payloads_match?(expected, recorded)
|
106
|
+
return expected.matches?(recorded) if expected.respond_to?(:matches?)
|
107
|
+
return expected.nil? || expected.empty? || expected == recorded
|
108
|
+
end
|
109
|
+
|
110
|
+
def add_matched(event, payload)
|
111
|
+
@matched << [event, payload]
|
112
|
+
end
|
113
|
+
|
114
|
+
def add_missing(event, payload)
|
115
|
+
@missing << [event, payload]
|
116
|
+
end
|
117
|
+
|
118
|
+
def failure_message
|
119
|
+
return "Error while publishing: %p" % [@error] if @error
|
120
|
+
|
121
|
+
messages = []
|
122
|
+
|
123
|
+
@missing.each do |event, payload|
|
124
|
+
message = "expected a '%s' event to be fired" % [event]
|
125
|
+
message << " with a payload of %p" % [payload] unless payload.nil?
|
126
|
+
message << " but none was."
|
127
|
+
|
128
|
+
messages << message
|
129
|
+
end
|
130
|
+
|
131
|
+
if @recorded_events.empty?
|
132
|
+
messages << "No events were sent."
|
133
|
+
else
|
134
|
+
parts = @recorded_events.map(&:inspect)
|
135
|
+
messages << "The following events were recorded: %s" % [parts.join(", ")]
|
136
|
+
end
|
137
|
+
|
138
|
+
return messages.join("\n")
|
139
|
+
end
|
140
|
+
|
141
|
+
def failure_message_when_negated
|
142
|
+
messages = []
|
143
|
+
@matched.each do |event, _payload|
|
144
|
+
message = "expected a '%s' event not to be fired" % [event]
|
145
|
+
message << " with a payload of %p" % [@expected_payload] if @expected_payload
|
146
|
+
message << " but one was."
|
147
|
+
messages << message
|
148
|
+
end
|
149
|
+
|
150
|
+
return messages.join("\n")
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# RSpec matcher -- set up an expectation that an event will be fired
|
155
|
+
# with the specified +eventname+ and optional +expected_payload+.
|
156
|
+
#
|
157
|
+
# expect {
|
158
|
+
# Myapp::Customer.create( attributes )
|
159
|
+
# }.to publish( 'myapp.customer.create' )
|
160
|
+
#
|
161
|
+
# expect {
|
162
|
+
# Myapp::Customer.create( attributes )
|
163
|
+
# }.to publish( 'myapp.customer.create', [1] )
|
164
|
+
#
|
165
|
+
# expect { enter_hatch() }.
|
166
|
+
# to publish( 'myapp.hatch.entered' ).
|
167
|
+
# with_payload( [4, 8, 15, 16, 23, 42] )
|
168
|
+
#
|
169
|
+
# expect { cook_potatoes() }.
|
170
|
+
# to publish( 'myapp.potatoes.cook' ).
|
171
|
+
# with_payload( including( a_hash_containing( taste: 'good' ) ) )
|
172
|
+
#
|
173
|
+
def publish(eventname=nil, expected_payload=nil)
|
174
|
+
return EventPublishedMatcher.new(eventname, expected_payload)
|
175
|
+
end
|
176
|
+
|
177
|
+
class PerformAsyncJobMatcher
|
178
|
+
include RSpec::Matchers::Composable
|
179
|
+
|
180
|
+
def initialize(job)
|
181
|
+
@job = job
|
182
|
+
end
|
183
|
+
|
184
|
+
# RSpec matcher API -- specify that this matcher supports expect with a block.
|
185
|
+
def supports_block_expectations?
|
186
|
+
true
|
187
|
+
end
|
188
|
+
|
189
|
+
# Return +true+ if the +given_proc+ is a valid callable.
|
190
|
+
def valid_proc?(given_proc)
|
191
|
+
return true if given_proc.respond_to?(:call)
|
192
|
+
|
193
|
+
warn "`perform_async_job` was called with non-proc object #{given_proc.inspect}"
|
194
|
+
return false
|
195
|
+
end
|
196
|
+
|
197
|
+
# RSpec matcher API -- return +true+ if the specified job ran successfully.
|
198
|
+
def matches?(given_proc)
|
199
|
+
return false unless self.valid_proc?(given_proc)
|
200
|
+
return self.run_isolated_job(given_proc)
|
201
|
+
end
|
202
|
+
|
203
|
+
# Run +given_proc+ in a 'clean' async environment, where 'clean' means:
|
204
|
+
# - Async jobs are subscribed to events
|
205
|
+
# - The only registered job is the matcher's job
|
206
|
+
def run_isolated_job(given_proc)
|
207
|
+
unless Amigo.synchronous_mode
|
208
|
+
warn "publish matcher used without synchronous_mode (use :async test metadata)"
|
209
|
+
return false
|
210
|
+
end
|
211
|
+
|
212
|
+
state = {on_error: self.method(:on_publish_error), subscribers: [], jobs: [@job]}
|
213
|
+
Amigo::SpecHelpers.snapshot_async_state(state) do
|
214
|
+
Amigo.install_amigo_jobs
|
215
|
+
given_proc.call
|
216
|
+
end
|
217
|
+
|
218
|
+
return @error.nil?
|
219
|
+
end
|
220
|
+
|
221
|
+
def on_publish_error(err)
|
222
|
+
@error = err
|
223
|
+
end
|
224
|
+
|
225
|
+
def failure_message
|
226
|
+
return "Job errored: %p" % [@error]
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
def perform_async_job(job)
|
231
|
+
return PerformAsyncJobMatcher.new(job)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
data/lib/amigo.rb
ADDED
@@ -0,0 +1,272 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "redis"
|
4
|
+
require "sidekiq"
|
5
|
+
require "sidekiq-cron"
|
6
|
+
|
7
|
+
# Host module and namespace for the Amigo async jobs system.
|
8
|
+
#
|
9
|
+
# The async job system is mostly decoupled into a few parts,
|
10
|
+
# so we can understand them in pieces.
|
11
|
+
# Those pieces are: Publish, Subscribe, Event Jobs, Routing, and Scheduled Jobs.
|
12
|
+
#
|
13
|
+
# Under the hood, the async job system uses Sidekiq.
|
14
|
+
# Sidekiq is a background job system that persists its data in Redis.
|
15
|
+
# Worker processes process the jobs off the queue.
|
16
|
+
#
|
17
|
+
# Publish
|
18
|
+
#
|
19
|
+
# The Amigo module has a very basic pub/sub system.
|
20
|
+
# You can use `Amigo.publish` to broadcast an event (event name and payload),
|
21
|
+
# and register subscribers to listen to the event.
|
22
|
+
# The actual event exchanged is a Amigo::Event, which is a simple wrapper object.
|
23
|
+
#
|
24
|
+
# Amigo.publish('myapp.auth.failed', email: params[:email])
|
25
|
+
#
|
26
|
+
# Subscribe
|
27
|
+
#
|
28
|
+
# Calling Amigo.register_subscriber registers a hook that listens for events published
|
29
|
+
# via Amigo.publish. All the subscriber does is send the events to the Router job.
|
30
|
+
#
|
31
|
+
# The subscriber should be enabled on clients that should emit events (so all web processes,
|
32
|
+
# console work that should have side effects like sending emails, and worker processes).
|
33
|
+
#
|
34
|
+
# Note that enabling the subscriber on worker processes means that it would be possible
|
35
|
+
# for a job to end up in an infinite loop
|
36
|
+
# (imagine if the audit logger, which records all published events, published an event).
|
37
|
+
# This is expected; be careful of infinite loops!
|
38
|
+
#
|
39
|
+
# Event Jobs
|
40
|
+
#
|
41
|
+
# Jobs must `include Amigo::Job`.
|
42
|
+
# As per best-practices when writing works, keep them as simple as possible,
|
43
|
+
# and put the business logic elsewhere.
|
44
|
+
#
|
45
|
+
# Standard jobs, which we call event-based jobs, generally respond to published events.
|
46
|
+
# Use the `on` method to define a glob pattern that is matched against event names:
|
47
|
+
#
|
48
|
+
# class Amigo::CustomerMailer
|
49
|
+
# include Amigo::Job
|
50
|
+
# on 'myapp.customer.created'
|
51
|
+
# def _perform(event)
|
52
|
+
# customer_id = event.payload.first
|
53
|
+
# # Send welcome email
|
54
|
+
# end
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# The 'on' pattern can be 'myapp.customer.*' to match all customer events for example,
|
58
|
+
# or '*' to match all events. The rules of matching follow File.fnmatch.
|
59
|
+
#
|
60
|
+
# Jobs must implement a `_perform` method, which takes a Amigo::Event.
|
61
|
+
# Note that normal Sidekiq workers use a 'perform' method that takes a variable number of arguments;
|
62
|
+
# the base Async::Job class has this method and delegates its business logic to the subclass _perform method.
|
63
|
+
#
|
64
|
+
# Routing
|
65
|
+
#
|
66
|
+
# There are two special workers that are important for the overall functioning of the system
|
67
|
+
# (and do not inherit from Job but rather than Sidekiq::Worker so they are not classified and treated as 'Jobs').
|
68
|
+
#
|
69
|
+
# The first is the AuditLogger, which is a basic job that logs all async events.
|
70
|
+
# This acts as a useful change log for the state of the database.
|
71
|
+
#
|
72
|
+
# The second special worker is the Router, which calls `perform` on the event Jobs
|
73
|
+
# that match the routing information, as explained in Jobs.
|
74
|
+
# It does this by filtering through all event-based jobs and performing the ones with a route match.
|
75
|
+
#
|
76
|
+
# Scheduled Jobs
|
77
|
+
#
|
78
|
+
# Scheduled jobs use the sidekiq-cron package: https://github.com/ondrejbartas/sidekiq-cron
|
79
|
+
# There is a separate base class, Amigo::ScheduledJob, that takes care of some standard job setup.
|
80
|
+
#
|
81
|
+
# To implement a scheduled job, `include Amigo::ScheduledJob`,
|
82
|
+
# call the `cron` method, and provide a `_perform` method.
|
83
|
+
# You can also use an optional `splay` method:
|
84
|
+
#
|
85
|
+
# class Amigo::CacheBuster
|
86
|
+
# include Amigo::ScheduledJob
|
87
|
+
# cron '*/10 * * * *'
|
88
|
+
# splay 60.seconds
|
89
|
+
# def _perform
|
90
|
+
# # Bust the cache
|
91
|
+
# end
|
92
|
+
# end
|
93
|
+
#
|
94
|
+
# This code will run once every 10 minutes or so (check out https://crontab.guru/ for testing cron expressions).
|
95
|
+
# The "or so" refers to the _splay_, which is a 'fuzz factor' of how close to the target interval
|
96
|
+
# the job may run. So in reality, this job will run every 9 to 11 minutes, due to the 60 second splay.
|
97
|
+
# Splay exists to avoid a "thundering herd" issue.
|
98
|
+
# Splay defaults to 30s; you may wish to always provide splay, whatever you think for your job.
|
99
|
+
#
|
100
|
+
class Amigo
|
101
|
+
require "amigo/job"
|
102
|
+
require "amigo/scheduled_job"
|
103
|
+
require "amigo/audit_logger"
|
104
|
+
require "amigo/router"
|
105
|
+
|
106
|
+
class << self
|
107
|
+
attr_accessor :structured_logging
|
108
|
+
|
109
|
+
# Proc called with [job, level, message, params].
|
110
|
+
# By default, logs to the job's logger (or Sidekiq's if job is nil).
|
111
|
+
# If structured_logging is true, the message will be an 'event' without any dynamic info,
|
112
|
+
# if false, the params will be rendered into the message so are suitable for unstructured logging.
|
113
|
+
attr_accessor :log_callback
|
114
|
+
|
115
|
+
def reset_logging
|
116
|
+
self.log_callback = ->(job, level, msg, _params) { (job || Sidekiq).logger.send(level, msg) }
|
117
|
+
self.structured_logging = false
|
118
|
+
end
|
119
|
+
|
120
|
+
def log(job, level, message, params)
|
121
|
+
if self.structured_logging
|
122
|
+
paramstr = params.map { |k, v| "#{k}=#{v}" }.join(" ")
|
123
|
+
message = "#{message} #{paramstr}"
|
124
|
+
end
|
125
|
+
self.log_callback[job, level, message, params]
|
126
|
+
end
|
127
|
+
|
128
|
+
# If true, perform event work synchronously rather than asynchronously.
|
129
|
+
# Only useful for testing.
|
130
|
+
attr_accessor :synchronous_mode
|
131
|
+
|
132
|
+
# Every subclass of Amigo::Job and Amigo::ScheduledJob goes here.
|
133
|
+
# It is used for routing and testing isolated jobs.
|
134
|
+
attr_accessor :registered_jobs
|
135
|
+
|
136
|
+
# An Array of callbacks to be run when an event is published.
|
137
|
+
attr_accessor :subscribers
|
138
|
+
|
139
|
+
# A single callback to be run when an event publication errors.
|
140
|
+
attr_accessor :on_publish_error
|
141
|
+
|
142
|
+
# Publish an event with the specified +eventname+ and +payload+
|
143
|
+
# to any configured publishers.
|
144
|
+
def publish(eventname, *payload)
|
145
|
+
ev = Event.new(SecureRandom.uuid, eventname, payload)
|
146
|
+
|
147
|
+
self.subscribers.to_a.each do |hook|
|
148
|
+
hook.call(ev)
|
149
|
+
rescue StandardError => e
|
150
|
+
self.log(nil, :error, "amigo_subscriber_hook_error", error: e, hook: hook, event: ev)
|
151
|
+
self.on_publish_error.call(e)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# Register a hook to be called when an event is sent.
|
156
|
+
def register_subscriber(&block)
|
157
|
+
raise LocalJumpError, "no block given" unless block
|
158
|
+
self.log nil, :info, "amigo_installed_subscriber", block: block
|
159
|
+
self.subscribers << block
|
160
|
+
return block
|
161
|
+
end
|
162
|
+
|
163
|
+
def unregister_subscriber(block_ref)
|
164
|
+
self.subscribers.delete(block_ref)
|
165
|
+
end
|
166
|
+
|
167
|
+
# Return an array of all Job subclasses that respond to event publishing (have patterns).
|
168
|
+
def registered_event_jobs
|
169
|
+
return self.registered_jobs.select(&:event_job?)
|
170
|
+
end
|
171
|
+
|
172
|
+
# Return an array of all Job subclasses that are scheduled (have intervals).
|
173
|
+
def registered_scheduled_jobs
|
174
|
+
return self.registered_jobs.select(&:scheduled_job?)
|
175
|
+
end
|
176
|
+
#
|
177
|
+
# Register a Amigo subscriber that will publish events to Sidekiq/Redis,
|
178
|
+
# for future routing.
|
179
|
+
|
180
|
+
# Install Amigo so that every publish will be sent to the AuditLogger job
|
181
|
+
# and will invoke the relevant jobs in registered_jobs via the Router job.
|
182
|
+
def install_amigo_jobs
|
183
|
+
return self.register_subscriber do |ev|
|
184
|
+
self._subscriber(ev)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
def _subscriber(event)
|
189
|
+
event_json = event.as_json
|
190
|
+
Amigo::AuditLogger.perform_async(event_json)
|
191
|
+
Amigo::Router.perform_async(event_json)
|
192
|
+
end
|
193
|
+
#
|
194
|
+
# # Start the scheduler.
|
195
|
+
# # This should generally be run in the Sidekiq worker process,
|
196
|
+
# # not a webserver process.
|
197
|
+
# def self.start_scheduler
|
198
|
+
# hash = self.scheduled_jobs.each_with_object({}) do |job, memo|
|
199
|
+
# self.logger.info "Scheduling %s every %p" % [job.name, job.cron_expr]
|
200
|
+
# memo[job.name] = {
|
201
|
+
# "class" => job.name,
|
202
|
+
# "cron" => job.cron_expr,
|
203
|
+
# }
|
204
|
+
# end
|
205
|
+
# load_errs = Sidekiq::Cron::Job.load_from_hash hash
|
206
|
+
# raise "Errors loading sidekiq-cron jobs: %p" % [load_errs] if load_errs.present?
|
207
|
+
# end
|
208
|
+
end
|
209
|
+
|
210
|
+
class Event
|
211
|
+
def self.from_json(o)
|
212
|
+
return self.new(o["id"], o["name"], o["payload"])
|
213
|
+
end
|
214
|
+
|
215
|
+
attr_reader :id, :name, :payload
|
216
|
+
|
217
|
+
def initialize(id, name, payload)
|
218
|
+
@id = id
|
219
|
+
@name = name
|
220
|
+
@payload = payload.map { |p| self.safe_stringify(p) }
|
221
|
+
end
|
222
|
+
|
223
|
+
def inspect
|
224
|
+
return "#<%p:%#0x [%s] %s %p>" % [
|
225
|
+
self.class,
|
226
|
+
self.object_id * 2,
|
227
|
+
self.id,
|
228
|
+
self.name,
|
229
|
+
self.payload,
|
230
|
+
]
|
231
|
+
end
|
232
|
+
|
233
|
+
def as_json(_opts={})
|
234
|
+
return {
|
235
|
+
"id" => self.id,
|
236
|
+
"name" => self.name,
|
237
|
+
"payload" => self.payload,
|
238
|
+
}
|
239
|
+
end
|
240
|
+
|
241
|
+
def to_json(opts={})
|
242
|
+
return JSON.dump(self.as_json(opts))
|
243
|
+
end
|
244
|
+
|
245
|
+
protected def safe_stringify(o)
|
246
|
+
return self.deep_stringify_keys(o) if o.is_a?(Hash)
|
247
|
+
return o
|
248
|
+
end
|
249
|
+
|
250
|
+
def deep_stringify_keys(hash)
|
251
|
+
stringified_hash = {}
|
252
|
+
hash.each do |k, v|
|
253
|
+
stringified_hash[k.to_s] =
|
254
|
+
case v
|
255
|
+
when Hash
|
256
|
+
self.deep_stringify_keys(v)
|
257
|
+
when Array
|
258
|
+
v.map { |i| i.is_a?(Hash) ? self.deep_stringify_keys(i) : i }
|
259
|
+
else
|
260
|
+
v
|
261
|
+
end
|
262
|
+
end
|
263
|
+
stringified_hash
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
Amigo.reset_logging
|
269
|
+
Amigo.synchronous_mode = false
|
270
|
+
Amigo.registered_jobs = []
|
271
|
+
Amigo.subscribers = Set.new
|
272
|
+
Amigo.on_publish_error = proc {}
|
metadata
ADDED
@@ -0,0 +1,165 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sidekiq-amigo
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Lithic Technology
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-03-04 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: sidekiq
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '6'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '6'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: sidekiq-cron
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rack
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '2.2'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '2.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.10'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.10'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec-core
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.10'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.10'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rubocop
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.11'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '1.11'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rubocop-performance
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '1.10'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '1.10'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: timecop
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description: 'sidekiq-amigo provides a pubsub system and other enhancements around
|
126
|
+
Sidekiq.
|
127
|
+
|
128
|
+
'
|
129
|
+
email: hello@lithic.tech
|
130
|
+
executables: []
|
131
|
+
extensions: []
|
132
|
+
extra_rdoc_files: []
|
133
|
+
files:
|
134
|
+
- lib/amigo.rb
|
135
|
+
- lib/amigo/audit_logger.rb
|
136
|
+
- lib/amigo/deprecated_jobs.rb
|
137
|
+
- lib/amigo/job.rb
|
138
|
+
- lib/amigo/router.rb
|
139
|
+
- lib/amigo/scheduled_job.rb
|
140
|
+
- lib/amigo/spec_helpers.rb
|
141
|
+
- lib/amigo/version.rb
|
142
|
+
homepage: https://github.com/lithictech/sidekiq-amigo
|
143
|
+
licenses:
|
144
|
+
- MIT
|
145
|
+
metadata: {}
|
146
|
+
post_install_message:
|
147
|
+
rdoc_options: []
|
148
|
+
require_paths:
|
149
|
+
- lib
|
150
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
151
|
+
requirements:
|
152
|
+
- - ">="
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: 2.7.0
|
155
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '0'
|
160
|
+
requirements: []
|
161
|
+
rubygems_version: 3.1.6
|
162
|
+
signing_key:
|
163
|
+
specification_version: 4
|
164
|
+
summary: Pubsub system and other enhancements around Sidekiq.
|
165
|
+
test_files: []
|