Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,49 @@
## [0.6.0] - 2026-07-21

### Added

- **Named job per service**: `.call_async` now enqueues a dedicated ActiveJob class generated for
each service — `Treasury::TransferGold::Service` gets `Treasury::TransferGold::ServiceJob` — instead
of a single generic `Servus::Extensions::Async::Job` for every invocation. Background dashboards
(Sidekiq, GoodJob, …) now show a meaningful per-service job name, so metrics, retries, and log
filtering align with the service that ran. These classes are generated automatically; you never
write or reference them, and the public `.call_async` API is unchanged.

- **`async` DSL for per-service job configuration**: Services can declare their ActiveJob options via
a class method — keyword shortcuts for the common cases plus a block, evaluated in the job class's
context, for the full ActiveJob surface.

```ruby
class Treasury::TransferGold::Service < Servus::Base
async queue: :critical, priority: 10

async do
retry_on Gringotts::Timeout, wait: 5.seconds, attempts: 3
discard_on ActiveJob::DeserializationError
end
end
```

These are class-level defaults; options passed inline to `.call_async` are layered on top per
enqueue and win for that call.

### Upgrading

Calling code is unaffected — `.call_async(**args)` on a service works exactly as before. Two things to
be aware of when deploying:

- **Drain your queues first.** The enqueue payload changed from `perform_later(name:, args:)` to
`perform_later(**args)` (the job class now identifies the service). Jobs enqueued by an older
version use the old shape and will not run after the upgrade, so let queues empty before deploying.
- **Workers must eager-load their services.** A job is resolved on the worker by its class name;
Servus defines each service's job when the service class loads, so Rails' production eager-loading
(the default) covers this. If you run workers with eager-loading off, ensure services get referenced
before their jobs run.

Only relevant if you reached into the extension internals: `Job#perform` now takes `(**args)` instead
of `(name:, args:)`, and `Servus::Extensions::Async::Errors::ServiceNotFoundError` has been removed
(services are no longer resolved from a serialized name string).

## [0.5.2] - 2026-05-25

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion gem/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
servus (0.5.2)
servus (0.6.0)
activesupport (>= 8.0)
json-schema (~> 5)

Expand Down
112 changes: 105 additions & 7 deletions gem/lib/servus/extensions/async/call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ module Extensions
module Async
# Provides asynchronous service execution via ActiveJob.
#
# This module extends {Servus::Base} with the {#call_async} method, enabling
# services to be executed in background jobs. Requires ActiveJob to be loaded.
# This module extends {Servus::Base} with the {#call_async} method and, for
# every service, generates a **named** ActiveJob subclass so background runners
# (Sidekiq, GoodJob, delayed_job, …) display a meaningful per-service job name
# instead of one generic class for every invocation.
#
# For +Treasury::TransferGold::Service+ the generated job is
# +Treasury::TransferGold::ServiceJob+ — a sibling constant in the service's
# parent namespace, subclassing {Servus::Extensions::Async::Job}.
#
# @see Call#call_async
# @see Servus::Extensions::Async::Job
module Call
# Enqueues the service for asynchronous execution via ActiveJob.
#
Expand All @@ -19,6 +26,10 @@ module Call
# Job-specific options are extracted and the remaining arguments are passed
# to the service's initialize method.
#
# The service's own named job class ({#servus_job_class}) is enqueued — the
# class itself identifies the service, so only the service arguments are
# serialized as the job payload.
#
# @param args [Hash] combined service arguments and job configuration options
# @option args [ActiveSupport::Duration] :wait delay before execution (e.g., 5.minutes)
# @option args [Time] :wait_until specific time to execute (e.g., 2.hours.from_now)
Expand Down Expand Up @@ -57,22 +68,109 @@ module Call
#
# @note Only available when ActiveJob is loaded (typically in Rails applications)
# @see Servus::Base.call
# @see #servus_job_class
def call_async(**args)
# Extract ActiveJob configuration options
job_options = args.slice(:wait, :wait_until, :queue, :priority)
job_options.merge!(args.delete(:job_options) || {}) # merge custom job options
job_options.compact!

# Remove special keys that shouldn't be passed to the service
args.except!(:wait, :wait_until, :queue, :priority, :job_options)

# Build job with optional delay, scheduling, or queue settings
job = job_options.any? ? Job.set(**job_options.compact) : Job

# Enqueue the job asynchronously
job.perform_later(name: name, args: args)
# The named job class identifies the service — only args are serialized.
job = job_options.any? ? servus_job_class.set(**job_options) : servus_job_class
job.perform_later(**args)
rescue StandardError => e
raise Errors::JobEnqueueError, "Failed to enqueue async job for #{self}: #{e.message}"
end

# Configures the service's named job class ({#servus_job_class}), exposing the
# underlying ActiveJob mechanics on a per-service basis.
#
# Provides keyword shortcuts for the two most common options (+queue+ and
# +priority+) and an optional block, evaluated in the job class's context, for
# the full ActiveJob surface (+retry_on+, +discard_on+, callbacks, etc.).
#
# Settings declared here are class-level defaults for the job. Options passed
# inline to {#call_async} (e.g. +queue:+, +wait:+) are layered on top of them
# per enqueue via +ActiveJob::Base.set+, so inline options win.
#
# @param queue [Symbol, String, nil] the queue to route the job to (+queue_as+)
# @param priority [Integer, nil] the job priority (adapter-dependent)
# @yield evaluated in the job class context for full ActiveJob configuration
# @return [Class<Servus::Extensions::Async::Job>] the configured job class
#
# @example Queue and priority
# class Payments::Charge::Service < Servus::Base
# async queue: :critical, priority: 10
# end
#
# @example Full ActiveJob configuration via a block
# class Payments::Charge::Service < Servus::Base
# async do
# retry_on Net::OpenTimeout, wait: 5.seconds, attempts: 3
# discard_on ActiveJob::DeserializationError
# end
# end
#
# @see #call_async
# @see #servus_job_class
def async(queue: nil, priority: nil, &block)
job = servus_job_class
job.queue_as(queue) if queue
job.priority = priority if priority
job.class_eval(&block) if block
job
end

# Returns the named ActiveJob class for this service, generating and memoizing
# it on first access.
#
# For named services the class is normally created eagerly by the {#inherited}
# hook when the service is defined; this accessor covers anonymous services
# (e.g. +Class.new(Servus::Base)+ in tests) whose name only becomes available
# later, and guarantees a class exists whenever one is needed.
#
# @return [Class<Servus::Extensions::Async::Job>] the service's job class
# @see #call_async
def servus_job_class
@servus_job_class ||= build_servus_job_class
end

# Eagerly generates the named job class when a service subclass is defined.
#
# Anonymous subclasses (+Class.new+) have no name yet, so their job is
# generated lazily by {#servus_job_class} instead.
#
# @param subclass [Class<Servus::Base>] the newly defined service
# @return [void]
# @api private
def inherited(subclass)
super
subclass.servus_job_class if subclass.name
end

private

# Generates a named job subclass for this service and installs it as a sibling
# constant in the service's parent namespace.
#
# The constant is +"#{ServiceConst}Job"+, so +Treasury::TransferGold::Service+
# yields +Treasury::TransferGold::ServiceJob+. Defining it eagerly (see
# {#inherited}) means Rails' production eager-load defines every service's job
# at boot, so worker processes can constantize the serialized job name.
#
# @return [Class<Servus::Extensions::Async::Job>] the generated job class
# @api private
def build_servus_job_class
klass = Class.new(Servus::Extensions::Async::Job)
klass.servus_service = self

module_parent.const_set("#{name.demodulize}Job", klass)

klass
end
end
end
end
Expand Down
10 changes: 0 additions & 10 deletions gem/lib/servus/extensions/async/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,6 @@ class AsyncError < StandardError; end
# Services::SendEmail::Service.call_async(user_id: 123)
# # => Servus::Extensions::Async::Errors::JobEnqueueError: Failed to enqueue async job
class JobEnqueueError < AsyncError; end

# Raised when a service class name cannot be found.
#
# This occurs during job execution when the service class string
# cannot be constantized, usually due to typos or deleted classes.
#
# @example
# Job.perform_later(name: "NonExistent::Service", args: {})
# # => Servus::Extensions::Async::Errors::ServiceNotFoundError: Service class 'NonExistent::Service' not found
class ServiceNotFoundError < AsyncError; end
end
end
end
Expand Down
3 changes: 3 additions & 0 deletions gem/lib/servus/extensions/async/ext.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ module Extensions
# @see Servus::Extensions::Async::Call
# @see Servus::Extensions::Async::Job
module Async
require 'active_support/core_ext/module/introspection' # Module#module_parent
require 'active_support/core_ext/string/inflections' # String#demodulize

require 'servus/extensions/async/errors'
require 'servus/extensions/async/job'
require 'servus/extensions/async/call'
Expand Down
61 changes: 27 additions & 34 deletions gem/lib/servus/extensions/async/job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,46 @@
module Servus
module Extensions
module Async
# ActiveJob for executing Servus services asynchronously.
# Abstract ActiveJob base class for executing Servus services asynchronously.
#
# This job is used by {Call#call_async} to execute services in the background.
# It receives the service class name and arguments, instantiates the service,
# and executes it via {Servus::Base.call}.
# This class is never enqueued directly. Instead, {Call} generates a named
# subclass per service (e.g. +Treasury::TransferGold::ServiceJob+) so that
# background runners like Sidekiq and GoodJob display a meaningful, per-service
# job name rather than one generic class for every invocation.
#
# @example Enqueued by call_async
# Services::SendEmail::Service.call_async(user_id: 123)
# # Internally enqueues:
# # Job.perform_later(name: "Services::SendEmail::Service", args: { user_id: 123 })
# Each generated subclass carries a reference to its owning service in
# {servus_service}, set at generation time. {#perform} uses that reference to
# route back through the standard {Servus::Base.call} lifecycle — validation,
# logging, benchmarking, guards, and event emission all run exactly as if the
# service had been called synchronously.
#
# @example The class Servus generates for a service
# Treasury::TransferGold::ServiceJob < Servus::Extensions::Async::Job
# Treasury::TransferGold::ServiceJob.servus_service
# # => Treasury::TransferGold::Service
#
# @see Servus::Extensions::Async::Call#call_async
# @api private
class Job < ActiveJob::Base
# The service class this job invokes. Set on each generated subclass when
# {Call.build_servus_job_class} creates it.
#
# @return [Class<Servus::Base>, nil] the owning service class
class_attribute :servus_service

queue_as :default

# Executes the service with the provided arguments.
# Executes the job's service with the provided arguments.
#
# Dynamically loads the service class by name and calls it with the
# provided keyword arguments.
# The service is identified by {servus_service} rather than a serialized
# name — the job class itself encodes which service to run.
#
# @param name [String] fully-qualified service class name
# @param args [Hash] keyword arguments to pass to the service
# @return [Servus::Support::Response] the service execution result
# @raise [Servus::Extensions::Async::Errors::ServiceNotFoundError] if service class doesn't exist
#
# @api private
def perform(name:, args:)
constantize!(name).call(**args)
end

private

attr_reader :klass

# Safely constantizes a class name string.
#
# Converts a string class name to its corresponding class constant,
# raising an error if the class doesn't exist.
#
# @param class_name [String] the service class name
# @return [Class] the service class
# @raise [Servus::Extensions::Async::Errors::ServiceNotFoundError] if class not found
#
# @api private
def constantize!(class_name)
"::#{class_name}".safe_constantize ||
(raise Errors::ServiceNotFoundError, "Service class '#{class_name}' not found.")
def perform(**args)
self.class.servus_service.call(**args)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion gem/lib/servus/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Servus
VERSION = '0.5.2'
VERSION = '0.6.0'
end
Loading