servus 1.0.0 → 1.0.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 +4 -4
- data/lib/servus/base.rb +1 -2
- data/lib/servus/extensions/async/call.rb +97 -10
- data/lib/servus/extensions/async/errors.rb +23 -0
- data/lib/servus/extensions/async/ext.rb +28 -0
- data/lib/servus/railtie.rb +1 -1
- data/lib/servus/schema.rb +1 -1
- data/lib/servus/support/logger.rb +16 -0
- data/lib/servus/version.rb +1 -1
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11fe445ce97bbd8300a08023e80eb1516011657d7412b6f5c9215f6c40aeb12d
|
|
4
|
+
data.tar.gz: 4cf1448c26b970cea781ccc2e7c21ddf5b8bde6f7703d070b59ea0809d752d1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f294661ff53e5340f52c8de15485c06c16820cb2b90b8d517c8e01322ee54fb0e7beec46f9560b3fe47d4c09b184474ccb5634368c2d902b0ab2c2106407d5bb
|
|
7
|
+
data.tar.gz: 3772632d087a92a26dcf24268570d1a4c1fd8887df493dd0a4c39b5cf899db8d5ec8e0bd3397738d4d64934f345ae9778eb7fef98e3a7f415d3f166f31005859
|
data/lib/servus/base.rb
CHANGED
|
@@ -246,7 +246,7 @@ module Servus
|
|
|
246
246
|
# @see #initialize
|
|
247
247
|
# @see #call
|
|
248
248
|
#
|
|
249
|
-
# rubocop:disable Metrics/MethodLength
|
|
249
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
250
250
|
def call(**args)
|
|
251
251
|
before_call(args)
|
|
252
252
|
|
|
@@ -272,7 +272,6 @@ module Servus
|
|
|
272
272
|
Logger.log_exception(self, e)
|
|
273
273
|
raise e
|
|
274
274
|
end
|
|
275
|
-
# rubocop:enable Metrics/MethodLength
|
|
276
275
|
|
|
277
276
|
# Executes pre-call hooks including logging and argument validation.
|
|
278
277
|
#
|
|
@@ -70,21 +70,18 @@ module Servus
|
|
|
70
70
|
# @see Servus::Base.call
|
|
71
71
|
# @see #servus_job_class
|
|
72
72
|
def call_async(**args)
|
|
73
|
-
|
|
74
|
-
job_options = args.slice(:wait, :wait_until, :queue, :priority)
|
|
75
|
-
job_options.merge!(args.delete(:job_options) || {}) # merge custom job options
|
|
76
|
-
job_options.compact!
|
|
73
|
+
refuse_nameless_job!
|
|
77
74
|
|
|
78
|
-
|
|
79
|
-
args.except!(:wait, :wait_until, :queue, :priority, :job_options)
|
|
75
|
+
job_options = extract_job_options!(args)
|
|
80
76
|
|
|
81
77
|
# The named job class identifies the service — only args are serialized.
|
|
82
78
|
job = job_options.any? ? servus_job_class.set(**job_options) : servus_job_class
|
|
83
79
|
job.perform_later(**args)
|
|
84
|
-
rescue Servus::Support::Errors::ServiceError, Servus::Events::Errors::Error
|
|
80
|
+
rescue Servus::Support::Errors::ServiceError, Servus::Events::Errors::Error, Errors::AsyncError
|
|
85
81
|
# With the :inline and :test adapters perform_later runs the service,
|
|
86
|
-
# so Servus's own errors surface here
|
|
87
|
-
#
|
|
82
|
+
# so Servus's own errors surface here — as do this extension's own,
|
|
83
|
+
# like the name-conflict refusal above. Wrapping any of them as an
|
|
84
|
+
# enqueue failure would blame the wrong layer.
|
|
88
85
|
raise
|
|
89
86
|
rescue StandardError => e
|
|
90
87
|
raise Errors::JobEnqueueError, "Failed to enqueue async job for #{self}: #{e.message}"
|
|
@@ -158,6 +155,23 @@ module Servus
|
|
|
158
155
|
|
|
159
156
|
private
|
|
160
157
|
|
|
158
|
+
# Splits ActiveJob configuration out of the combined argument hash, mutating
|
|
159
|
+
# +args+ so only the service's own arguments remain.
|
|
160
|
+
#
|
|
161
|
+
# @param args [Hash] combined service arguments and job configuration options
|
|
162
|
+
# @return [Hash] the ActiveJob options (+wait+, +queue+, …) for +set+
|
|
163
|
+
# @api private
|
|
164
|
+
def extract_job_options!(args)
|
|
165
|
+
job_options = args.slice(:wait, :wait_until, :queue, :priority)
|
|
166
|
+
job_options.merge!(args.delete(:job_options) || {}) # merge custom job options
|
|
167
|
+
job_options.compact!
|
|
168
|
+
|
|
169
|
+
# Remove special keys that shouldn't be passed to the service
|
|
170
|
+
args.except!(:wait, :wait_until, :queue, :priority, :job_options)
|
|
171
|
+
|
|
172
|
+
job_options
|
|
173
|
+
end
|
|
174
|
+
|
|
161
175
|
# Generates a named job subclass for this service and installs it as a sibling
|
|
162
176
|
# constant in the service's parent namespace.
|
|
163
177
|
#
|
|
@@ -166,6 +180,12 @@ module Servus
|
|
|
166
180
|
# {#inherited}) means Rails' production eager-load defines every service's job
|
|
167
181
|
# at boot, so worker processes can constantize the serialized job name.
|
|
168
182
|
#
|
|
183
|
+
# An app that already owns that constant keeps it: +Foo+ alongside a
|
|
184
|
+
# hand-written +FooJob+ is ordinary Rails, and overwriting it replaced a real
|
|
185
|
+
# job class with this one silently. The generated class is still returned so
|
|
186
|
+
# the service keeps working, but it is unnamed — {#call_async} refuses it with
|
|
187
|
+
# {Errors::JobNameConflictError} rather than enqueue an unresolvable job.
|
|
188
|
+
#
|
|
169
189
|
# @return [Class<Servus::Extensions::Async::Job>] the generated job class
|
|
170
190
|
# @api private
|
|
171
191
|
def build_servus_job_class
|
|
@@ -174,10 +194,77 @@ module Servus
|
|
|
174
194
|
klass = Class.new(Servus::Extensions::Async::Job)
|
|
175
195
|
klass.servus_service = self
|
|
176
196
|
|
|
177
|
-
|
|
197
|
+
publish_job_const(servus_job_const_name, klass)
|
|
178
198
|
|
|
179
199
|
klass
|
|
180
200
|
end
|
|
201
|
+
|
|
202
|
+
# @return [String] the sibling constant the service's job is published under
|
|
203
|
+
# @api private
|
|
204
|
+
def servus_job_const_name
|
|
205
|
+
"#{name.demodulize}Job"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A job with no name lost its constant to the application (see
|
|
209
|
+
# {#publish_job_const}). ActiveJob serializes a job by its class name, so
|
|
210
|
+
# enqueueing it would succeed and then fail on the worker at
|
|
211
|
+
# deserialization — refuse at the call site instead.
|
|
212
|
+
#
|
|
213
|
+
# @raise [Errors::JobNameConflictError] when the service's job is unnamed
|
|
214
|
+
# @return [void]
|
|
215
|
+
# @api private
|
|
216
|
+
def refuse_nameless_job!
|
|
217
|
+
return unless servus_job_class.name.nil?
|
|
218
|
+
|
|
219
|
+
raise Errors::JobNameConflictError.for(self, qualified_const_name(servus_job_const_name))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Installs the generated job as a sibling constant, unless the application
|
|
223
|
+
# owns that name — in which case its class is left alone and the skip is
|
|
224
|
+
# logged.
|
|
225
|
+
#
|
|
226
|
+
# @param const_name [String]
|
|
227
|
+
# @param klass [Class<Servus::Extensions::Async::Job>]
|
|
228
|
+
# @return [void]
|
|
229
|
+
# @api private
|
|
230
|
+
def publish_job_const(const_name, klass)
|
|
231
|
+
unless job_const_available?(const_name)
|
|
232
|
+
return Servus::Support::Logger.log_job_class_conflict(self, qualified_const_name(const_name))
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Reclaiming a stale generated job: drop it first so Ruby does not warn
|
|
236
|
+
# about an already-initialized constant on every reload.
|
|
237
|
+
module_parent.send(:remove_const, const_name) if module_parent.const_defined?(const_name, false)
|
|
238
|
+
module_parent.const_set(const_name, klass)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Whether this service may publish its job class as +const_name+.
|
|
242
|
+
#
|
|
243
|
+
# Free names are available, and so is a name already holding a generated job —
|
|
244
|
+
# that is our own constant from a previous definition, which survives a
|
|
245
|
+
# development reload when the parent namespace is +Object+ and therefore
|
|
246
|
+
# unmanaged by Zeitwerk.
|
|
247
|
+
#
|
|
248
|
+
# A pending autoload is treated as the app's without resolving it: forcing the
|
|
249
|
+
# load here would run app code in the middle of defining a service.
|
|
250
|
+
#
|
|
251
|
+
# @param const_name [String]
|
|
252
|
+
# @return [Boolean]
|
|
253
|
+
# @api private
|
|
254
|
+
def job_const_available?(const_name)
|
|
255
|
+
return true unless module_parent.const_defined?(const_name, false)
|
|
256
|
+
return false if module_parent.autoload?(const_name)
|
|
257
|
+
|
|
258
|
+
existing = module_parent.const_get(const_name, false)
|
|
259
|
+
existing.is_a?(Class) && existing <= Servus::Extensions::Async::Job
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# @param const_name [String]
|
|
263
|
+
# @return [String] the constant's fully qualified name
|
|
264
|
+
# @api private
|
|
265
|
+
def qualified_const_name(const_name)
|
|
266
|
+
module_parent.equal?(Object) ? const_name : "#{module_parent.name}::#{const_name}"
|
|
267
|
+
end
|
|
181
268
|
end
|
|
182
269
|
end
|
|
183
270
|
end
|
|
@@ -22,6 +22,29 @@ module Servus
|
|
|
22
22
|
# Services::SendEmail::Service.call_async(user_id: 123)
|
|
23
23
|
# # => Servus::Extensions::Async::Errors::JobEnqueueError: Failed to enqueue async job
|
|
24
24
|
class JobEnqueueError < AsyncError; end
|
|
25
|
+
|
|
26
|
+
# Raised when +call_async+ is invoked on a service whose generated job was
|
|
27
|
+
# never published because the application already owns the constant.
|
|
28
|
+
#
|
|
29
|
+
# The generated job class exists but has no name, and ActiveJob serializes a
|
|
30
|
+
# job by its class name — enqueueing it would succeed and then fail on the
|
|
31
|
+
# worker at deserialization, far from the call site. Refusing here keeps the
|
|
32
|
+
# failure where it was caused.
|
|
33
|
+
#
|
|
34
|
+
# @see Servus::Extensions::Async::Call#call_async
|
|
35
|
+
class JobNameConflictError < AsyncError
|
|
36
|
+
# @param service [Class] the service whose job constant is taken
|
|
37
|
+
# @param const_name [String] the constant the application owns
|
|
38
|
+
# @return [JobNameConflictError]
|
|
39
|
+
def self.for(service, const_name)
|
|
40
|
+
new(
|
|
41
|
+
"Cannot enqueue #{service.name}: its generated job was not published because " \
|
|
42
|
+
"#{const_name} is already defined by the application. An unnamed job would " \
|
|
43
|
+
'enqueue but no worker could deserialize it. Rename the service or the ' \
|
|
44
|
+
"application's #{const_name} to stop the collision."
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
25
48
|
end
|
|
26
49
|
end
|
|
27
50
|
end
|
|
@@ -12,6 +12,7 @@ module Servus
|
|
|
12
12
|
module Async
|
|
13
13
|
require 'active_support/core_ext/module/introspection' # Module#module_parent
|
|
14
14
|
require 'active_support/core_ext/string/inflections' # String#demodulize
|
|
15
|
+
require 'active_support/core_ext/class/subclasses' # Class#descendants
|
|
15
16
|
|
|
16
17
|
require 'servus/extensions/async/errors'
|
|
17
18
|
require 'servus/extensions/async/job'
|
|
@@ -21,6 +22,33 @@ module Servus
|
|
|
21
22
|
#
|
|
22
23
|
# @api private
|
|
23
24
|
module Ext; end
|
|
25
|
+
|
|
26
|
+
# Installs async support on {Servus::Base} and backfills job classes for
|
|
27
|
+
# services that were already defined.
|
|
28
|
+
#
|
|
29
|
+
# The backfill exists because {Call#inherited} — which publishes a service's
|
|
30
|
+
# job constant — only exists once this module is installed, and installation
|
|
31
|
+
# is deferred to +on_load(:active_job)+. Servus's own railtie force-loads
|
|
32
|
+
# +app/events/*_event.rb+, and every service named by an +enqueue+ there loads
|
|
33
|
+
# with it, typically before anything has touched +ActiveJob::Base+. Those
|
|
34
|
+
# services would otherwise never publish a job constant, and a worker — which
|
|
35
|
+
# resolves jobs by name and never calls {Call#call_async} — would fail
|
|
36
|
+
# deserialization with +ActiveJob::UnknownJobClassError+.
|
|
37
|
+
#
|
|
38
|
+
# Only services still reachable under their own name are backfilled. An
|
|
39
|
+
# anonymous service has no constant to publish, and a class whose name no
|
|
40
|
+
# longer resolves to it — replaced by a reload, or defined under a namespace
|
|
41
|
+
# that has since gone — has nothing to publish it under either.
|
|
42
|
+
#
|
|
43
|
+
# @return [void]
|
|
44
|
+
# @api private
|
|
45
|
+
def self.install!
|
|
46
|
+
Servus::Base.extend(Call)
|
|
47
|
+
|
|
48
|
+
Servus::Base.descendants.each do |service|
|
|
49
|
+
service.servus_job_class if service.name&.safe_constantize.equal?(service)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
24
52
|
end
|
|
25
53
|
end
|
|
26
54
|
end
|
data/lib/servus/railtie.rb
CHANGED
data/lib/servus/schema.rb
CHANGED
|
@@ -175,7 +175,7 @@ module Servus
|
|
|
175
175
|
# Servus::Schema.ref('core', '$defs', 'amount')
|
|
176
176
|
# # => { "$ref" => "#/core/$defs/amount" }
|
|
177
177
|
def ref(key, *path)
|
|
178
|
-
{ '$ref' => "#/#{[key, *path].
|
|
178
|
+
{ '$ref' => "#/#{[key, *path].join('/')}" }
|
|
179
179
|
end
|
|
180
180
|
|
|
181
181
|
# Compiles a schema, replacing every +$ref+ with the fragment it names.
|
|
@@ -104,6 +104,22 @@ module Servus
|
|
|
104
104
|
logger.warn("Schema fragment #{key.inspect} was already registered with a different value; replacing it.")
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Logs that a service could not publish its generated job class because the
|
|
108
|
+
# constant is already taken by the application.
|
|
109
|
+
#
|
|
110
|
+
# The application's class is left alone. The service still runs, but its
|
|
111
|
+
# generated job has no name, so +call_async+ on it cannot be serialized.
|
|
112
|
+
#
|
|
113
|
+
# @param service_class [Class<Servus::Base>] The service whose job was not published
|
|
114
|
+
# @param const_name [String] The constant the application already owns
|
|
115
|
+
def self.log_job_class_conflict(service_class, const_name)
|
|
116
|
+
logger.warn(
|
|
117
|
+
"#{service_class.name} did not publish its generated job as #{const_name} — that constant " \
|
|
118
|
+
'is already defined by the application. The application class is unchanged; ' \
|
|
119
|
+
"#{service_class.name}.call_async cannot be enqueued until the service or the job is renamed."
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
107
123
|
# Filters parameters for logging based on the configured filter list.
|
|
108
124
|
#
|
|
109
125
|
# @param params [Hash] The parameters to filter
|
data/lib/servus/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: servus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Scholl
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -28,16 +28,22 @@ dependencies:
|
|
|
28
28
|
name: json-schema
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
|
-
- - "
|
|
31
|
+
- - ">="
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
33
|
version: '5'
|
|
34
|
+
- - "<"
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '7'
|
|
34
37
|
type: :runtime
|
|
35
38
|
prerelease: false
|
|
36
39
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
40
|
requirements:
|
|
38
|
-
- - "
|
|
41
|
+
- - ">="
|
|
39
42
|
- !ruby/object:Gem::Version
|
|
40
43
|
version: '5'
|
|
44
|
+
- - "<"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '7'
|
|
41
47
|
- !ruby/object:Gem::Dependency
|
|
42
48
|
name: actionpack
|
|
43
49
|
requirement: !ruby/object:Gem::Requirement
|