servus 0.5.2 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15cec1ebe831437428e84374b7b7a83cc34c1f4cbcdc13bfe1c847eca5c1c9b3
4
- data.tar.gz: 2b371d1069a549fab5af3de5aca80d8be029850cc0c1e3378e7643c742b917dd
3
+ metadata.gz: eaa6e4a6098ddae071c236515fc72aa59fdc3707aa43bb145b14eb6830bef5dd
4
+ data.tar.gz: e418da17ac2136fd8daac9a22b6fd3f2ff686fb9fc681b131eb4060e7363b1eb
5
5
  SHA512:
6
- metadata.gz: a66d2a07c3c564148e612fac96ab22e350c9483938226a495cc7b97b3b96ad979a4dba26b3bc2b5090bb3fc4c3c5016647f186f34cbe93bdc9f8f0f329205572
7
- data.tar.gz: 56d23b1caac58e6d1c62062b80ecd85bb50784f034709fb5228d742e5de0c76e6a096bf59d45d67d84b0e024e2a0ba7f8f283db2027e543aa5543ad383eee2b3
6
+ metadata.gz: 73a3a30becf0f5cc0da3829239332a8e619f4f156713b7743251b76b0213aece56c802f572f4aa9b761db4858e7266a7e11b14344bf0412d78eb34f1224f2384
7
+ data.tar.gz: f091bb4cf55750907ca52c27c2aedaa0484692895bec7144368581d49f8a5741246588fc7e2b29c2d712795cffd1930656ac59b5434150180cd61b558faa3e13
@@ -5,10 +5,17 @@ module Servus
5
5
  module Async
6
6
  # Provides asynchronous service execution via ActiveJob.
7
7
  #
8
- # This module extends {Servus::Base} with the {#call_async} method, enabling
9
- # services to be executed in background jobs. Requires ActiveJob to be loaded.
8
+ # This module extends {Servus::Base} with the {#call_async} method and, for
9
+ # every service, generates a **named** ActiveJob subclass so background runners
10
+ # (Sidekiq, GoodJob, delayed_job, …) display a meaningful per-service job name
11
+ # instead of one generic class for every invocation.
12
+ #
13
+ # For +Treasury::TransferGold::Service+ the generated job is
14
+ # +Treasury::TransferGold::ServiceJob+ — a sibling constant in the service's
15
+ # parent namespace, subclassing {Servus::Extensions::Async::Job}.
10
16
  #
11
17
  # @see Call#call_async
18
+ # @see Servus::Extensions::Async::Job
12
19
  module Call
13
20
  # Enqueues the service for asynchronous execution via ActiveJob.
14
21
  #
@@ -19,6 +26,10 @@ module Servus
19
26
  # Job-specific options are extracted and the remaining arguments are passed
20
27
  # to the service's initialize method.
21
28
  #
29
+ # The service's own named job class ({#servus_job_class}) is enqueued — the
30
+ # class itself identifies the service, so only the service arguments are
31
+ # serialized as the job payload.
32
+ #
22
33
  # @param args [Hash] combined service arguments and job configuration options
23
34
  # @option args [ActiveSupport::Duration] :wait delay before execution (e.g., 5.minutes)
24
35
  # @option args [Time] :wait_until specific time to execute (e.g., 2.hours.from_now)
@@ -57,22 +68,109 @@ module Servus
57
68
  #
58
69
  # @note Only available when ActiveJob is loaded (typically in Rails applications)
59
70
  # @see Servus::Base.call
71
+ # @see #servus_job_class
60
72
  def call_async(**args)
61
73
  # Extract ActiveJob configuration options
62
74
  job_options = args.slice(:wait, :wait_until, :queue, :priority)
63
75
  job_options.merge!(args.delete(:job_options) || {}) # merge custom job options
76
+ job_options.compact!
64
77
 
65
78
  # Remove special keys that shouldn't be passed to the service
66
79
  args.except!(:wait, :wait_until, :queue, :priority, :job_options)
67
80
 
68
- # Build job with optional delay, scheduling, or queue settings
69
- job = job_options.any? ? Job.set(**job_options.compact) : Job
70
-
71
- # Enqueue the job asynchronously
72
- job.perform_later(name: name, args: args)
81
+ # The named job class identifies the service only args are serialized.
82
+ job = job_options.any? ? servus_job_class.set(**job_options) : servus_job_class
83
+ job.perform_later(**args)
73
84
  rescue StandardError => e
74
85
  raise Errors::JobEnqueueError, "Failed to enqueue async job for #{self}: #{e.message}"
75
86
  end
87
+
88
+ # Configures the service's named job class ({#servus_job_class}), exposing the
89
+ # underlying ActiveJob mechanics on a per-service basis.
90
+ #
91
+ # Provides keyword shortcuts for the two most common options (+queue+ and
92
+ # +priority+) and an optional block, evaluated in the job class's context, for
93
+ # the full ActiveJob surface (+retry_on+, +discard_on+, callbacks, etc.).
94
+ #
95
+ # Settings declared here are class-level defaults for the job. Options passed
96
+ # inline to {#call_async} (e.g. +queue:+, +wait:+) are layered on top of them
97
+ # per enqueue via +ActiveJob::Base.set+, so inline options win.
98
+ #
99
+ # @param queue [Symbol, String, nil] the queue to route the job to (+queue_as+)
100
+ # @param priority [Integer, nil] the job priority (adapter-dependent)
101
+ # @yield evaluated in the job class context for full ActiveJob configuration
102
+ # @return [Class<Servus::Extensions::Async::Job>] the configured job class
103
+ #
104
+ # @example Queue and priority
105
+ # class Payments::Charge::Service < Servus::Base
106
+ # async queue: :critical, priority: 10
107
+ # end
108
+ #
109
+ # @example Full ActiveJob configuration via a block
110
+ # class Payments::Charge::Service < Servus::Base
111
+ # async do
112
+ # retry_on Net::OpenTimeout, wait: 5.seconds, attempts: 3
113
+ # discard_on ActiveJob::DeserializationError
114
+ # end
115
+ # end
116
+ #
117
+ # @see #call_async
118
+ # @see #servus_job_class
119
+ def async(queue: nil, priority: nil, &block)
120
+ job = servus_job_class
121
+ job.queue_as(queue) if queue
122
+ job.priority = priority if priority
123
+ job.class_eval(&block) if block
124
+ job
125
+ end
126
+
127
+ # Returns the named ActiveJob class for this service, generating and memoizing
128
+ # it on first access.
129
+ #
130
+ # For named services the class is normally created eagerly by the {#inherited}
131
+ # hook when the service is defined; this accessor covers anonymous services
132
+ # (e.g. +Class.new(Servus::Base)+ in tests) whose name only becomes available
133
+ # later, and guarantees a class exists whenever one is needed.
134
+ #
135
+ # @return [Class<Servus::Extensions::Async::Job>] the service's job class
136
+ # @see #call_async
137
+ def servus_job_class
138
+ @servus_job_class ||= build_servus_job_class
139
+ end
140
+
141
+ # Eagerly generates the named job class when a service subclass is defined.
142
+ #
143
+ # Anonymous subclasses (+Class.new+) have no name yet, so their job is
144
+ # generated lazily by {#servus_job_class} instead.
145
+ #
146
+ # @param subclass [Class<Servus::Base>] the newly defined service
147
+ # @return [void]
148
+ # @api private
149
+ def inherited(subclass)
150
+ super
151
+ subclass.servus_job_class if subclass.name
152
+ end
153
+
154
+ private
155
+
156
+ # Generates a named job subclass for this service and installs it as a sibling
157
+ # constant in the service's parent namespace.
158
+ #
159
+ # The constant is +"#{ServiceConst}Job"+, so +Treasury::TransferGold::Service+
160
+ # yields +Treasury::TransferGold::ServiceJob+. Defining it eagerly (see
161
+ # {#inherited}) means Rails' production eager-load defines every service's job
162
+ # at boot, so worker processes can constantize the serialized job name.
163
+ #
164
+ # @return [Class<Servus::Extensions::Async::Job>] the generated job class
165
+ # @api private
166
+ def build_servus_job_class
167
+ klass = Class.new(Servus::Extensions::Async::Job)
168
+ klass.servus_service = self
169
+
170
+ module_parent.const_set("#{name.demodulize}Job", klass)
171
+
172
+ klass
173
+ end
76
174
  end
77
175
  end
78
176
  end
@@ -22,16 +22,6 @@ 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 a service class name cannot be found.
27
- #
28
- # This occurs during job execution when the service class string
29
- # cannot be constantized, usually due to typos or deleted classes.
30
- #
31
- # @example
32
- # Job.perform_later(name: "NonExistent::Service", args: {})
33
- # # => Servus::Extensions::Async::Errors::ServiceNotFoundError: Service class 'NonExistent::Service' not found
34
- class ServiceNotFoundError < AsyncError; end
35
25
  end
36
26
  end
37
27
  end
@@ -10,6 +10,9 @@ module Servus
10
10
  # @see Servus::Extensions::Async::Call
11
11
  # @see Servus::Extensions::Async::Job
12
12
  module Async
13
+ require 'active_support/core_ext/module/introspection' # Module#module_parent
14
+ require 'active_support/core_ext/string/inflections' # String#demodulize
15
+
13
16
  require 'servus/extensions/async/errors'
14
17
  require 'servus/extensions/async/job'
15
18
  require 'servus/extensions/async/call'
@@ -3,53 +3,46 @@
3
3
  module Servus
4
4
  module Extensions
5
5
  module Async
6
- # ActiveJob for executing Servus services asynchronously.
6
+ # Abstract ActiveJob base class for executing Servus services asynchronously.
7
7
  #
8
- # This job is used by {Call#call_async} to execute services in the background.
9
- # It receives the service class name and arguments, instantiates the service,
10
- # and executes it via {Servus::Base.call}.
8
+ # This class is never enqueued directly. Instead, {Call} generates a named
9
+ # subclass per service (e.g. +Treasury::TransferGold::ServiceJob+) so that
10
+ # background runners like Sidekiq and GoodJob display a meaningful, per-service
11
+ # job name rather than one generic class for every invocation.
11
12
  #
12
- # @example Enqueued by call_async
13
- # Services::SendEmail::Service.call_async(user_id: 123)
14
- # # Internally enqueues:
15
- # # Job.perform_later(name: "Services::SendEmail::Service", args: { user_id: 123 })
13
+ # Each generated subclass carries a reference to its owning service in
14
+ # {servus_service}, set at generation time. {#perform} uses that reference to
15
+ # route back through the standard {Servus::Base.call} lifecycle — validation,
16
+ # logging, benchmarking, guards, and event emission all run exactly as if the
17
+ # service had been called synchronously.
16
18
  #
19
+ # @example The class Servus generates for a service
20
+ # Treasury::TransferGold::ServiceJob < Servus::Extensions::Async::Job
21
+ # Treasury::TransferGold::ServiceJob.servus_service
22
+ # # => Treasury::TransferGold::Service
23
+ #
24
+ # @see Servus::Extensions::Async::Call#call_async
17
25
  # @api private
18
26
  class Job < ActiveJob::Base
27
+ # The service class this job invokes. Set on each generated subclass when
28
+ # {Call.build_servus_job_class} creates it.
29
+ #
30
+ # @return [Class<Servus::Base>, nil] the owning service class
31
+ class_attribute :servus_service
32
+
19
33
  queue_as :default
20
34
 
21
- # Executes the service with the provided arguments.
35
+ # Executes the job's service with the provided arguments.
22
36
  #
23
- # Dynamically loads the service class by name and calls it with the
24
- # provided keyword arguments.
37
+ # The service is identified by {servus_service} rather than a serialized
38
+ # name the job class itself encodes which service to run.
25
39
  #
26
- # @param name [String] fully-qualified service class name
27
40
  # @param args [Hash] keyword arguments to pass to the service
28
41
  # @return [Servus::Support::Response] the service execution result
29
- # @raise [Servus::Extensions::Async::Errors::ServiceNotFoundError] if service class doesn't exist
30
- #
31
- # @api private
32
- def perform(name:, args:)
33
- constantize!(name).call(**args)
34
- end
35
-
36
- private
37
-
38
- attr_reader :klass
39
-
40
- # Safely constantizes a class name string.
41
- #
42
- # Converts a string class name to its corresponding class constant,
43
- # raising an error if the class doesn't exist.
44
- #
45
- # @param class_name [String] the service class name
46
- # @return [Class] the service class
47
- # @raise [Servus::Extensions::Async::Errors::ServiceNotFoundError] if class not found
48
42
  #
49
43
  # @api private
50
- def constantize!(class_name)
51
- "::#{class_name}".safe_constantize ||
52
- (raise Errors::ServiceNotFoundError, "Service class '#{class_name}' not found.")
44
+ def perform(**args)
45
+ self.class.servus_service.call(**args)
53
46
  end
54
47
  end
55
48
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Servus
4
- VERSION = '0.5.2'
4
+ VERSION = '0.6.0'
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: 0.5.2
4
+ version: 0.6.0
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-05-25 00:00:00.000000000 Z
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport