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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc5b23e5aa35d4127cfb7afe7e784dced5eca17c776f3561a1c00037b7b59e74
4
- data.tar.gz: b70c8e9b14cd59b2e4d1eb38a0846f3cdbbabf65d372204690a4458b5d14f8af
3
+ metadata.gz: 11fe445ce97bbd8300a08023e80eb1516011657d7412b6f5c9215f6c40aeb12d
4
+ data.tar.gz: 4cf1448c26b970cea781ccc2e7c21ddf5b8bde6f7703d070b59ea0809d752d1b
5
5
  SHA512:
6
- metadata.gz: 6990920d0e7f5b6c9285ce9636a5fef5b301a329f05ee03316c17b4e268e7838cb83e0dd2d352e147e6865e2d5a58cc274940bb2110e52d8cdec3af3d7509f1b
7
- data.tar.gz: 5b90d1643831c078ad81ad09ff04aa9baa9b84a5203f22c31e91d5cb42aa869b2e2ca089664effaab2f1c78b1d7a504840e2add6236e2b10478b44cd2bc9c17d
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
- # Extract ActiveJob configuration options
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
- # Remove special keys that shouldn't be passed to the service
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. Wrapping them as an enqueue
87
- # failure would blame the wrong layer.
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
- module_parent.const_set("#{name.demodulize}Job", klass)
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
@@ -14,7 +14,7 @@ module Servus
14
14
  initializer 'servus.job_async' do
15
15
  ActiveSupport.on_load(:active_job) do
16
16
  require 'servus/extensions/async/ext'
17
- Servus::Base.extend Servus::Extensions::Async::Call
17
+ Servus::Extensions::Async.install!
18
18
  end
19
19
  end
20
20
 
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].map(&:to_s).join('/')}" }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Servus
4
- VERSION = '1.0.0'
4
+ VERSION = '1.0.2'
5
5
  end
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.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-08-27 00:00:00.000000000 Z
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