diff --git a/CHANGELOG.md b/CHANGELOG.md index c01ee3e..af67fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/gem/Gemfile.lock b/gem/Gemfile.lock index d4bda84..abec35d 100644 --- a/gem/Gemfile.lock +++ b/gem/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - servus (0.5.2) + servus (0.6.0) activesupport (>= 8.0) json-schema (~> 5) diff --git a/gem/lib/servus/extensions/async/call.rb b/gem/lib/servus/extensions/async/call.rb index 48f4386..6cfcd40 100644 --- a/gem/lib/servus/extensions/async/call.rb +++ b/gem/lib/servus/extensions/async/call.rb @@ -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. # @@ -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) @@ -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] 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] 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] 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] 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 diff --git a/gem/lib/servus/extensions/async/errors.rb b/gem/lib/servus/extensions/async/errors.rb index 826b2e9..04eb483 100644 --- a/gem/lib/servus/extensions/async/errors.rb +++ b/gem/lib/servus/extensions/async/errors.rb @@ -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 diff --git a/gem/lib/servus/extensions/async/ext.rb b/gem/lib/servus/extensions/async/ext.rb index 621ffa5..6735fff 100644 --- a/gem/lib/servus/extensions/async/ext.rb +++ b/gem/lib/servus/extensions/async/ext.rb @@ -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' diff --git a/gem/lib/servus/extensions/async/job.rb b/gem/lib/servus/extensions/async/job.rb index 04e4f34..e586f69 100644 --- a/gem/lib/servus/extensions/async/job.rb +++ b/gem/lib/servus/extensions/async/job.rb @@ -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, 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 diff --git a/gem/lib/servus/version.rb b/gem/lib/servus/version.rb index b015837..91b17f4 100644 --- a/gem/lib/servus/version.rb +++ b/gem/lib/servus/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Servus - VERSION = '0.5.2' + VERSION = '0.6.0' end diff --git a/gem/spec/servus/extensions/async/call_spec.rb b/gem/spec/servus/extensions/async/call_spec.rb index 0813eac..70c5926 100644 --- a/gem/spec/servus/extensions/async/call_spec.rb +++ b/gem/spec/servus/extensions/async/call_spec.rb @@ -5,47 +5,55 @@ require 'servus/extensions/async/ext' RSpec.describe '.call_async extension', type: :job do - let(:job_class) { Servus::Extensions::Async::Job } - - # Define a dummy service class for testing - before do - # Make sure the extension is loaded and applied - Servus::Base.extend(Servus::Extensions::Async::Call) - - stub_const('DummyService', Class.new(Servus::Base) do - def initialize(arg1:, arg2:) - super() - @arg1 = arg1 - @arg2 = arg2 - end - - def call - success("Called with #{@arg1} and #{@arg2}") - end - end) - end + # Make sure the extension is loaded and applied. + before { Servus::Base.extend(Servus::Extensions::Async::Call) } + + let(:job_class) { AsyncEmailService.servus_job_class } it 'responds to .call_async' do - expect(DummyService).to respond_to(:call_async) + expect(AsyncEmailService).to respond_to(:call_async) + end + + it 'generates a named sibling job class bound to the service' do + expect(AsyncEmailService.servus_job_class).to eq(AsyncEmailServiceJob) + expect(AsyncEmailServiceJob.servus_service).to eq(AsyncEmailService) + expect(AsyncEmailServiceJob.ancestors).to include(Servus::Extensions::Async::Job) end - it 'enqueues Servus::Extensions::Async::Job with name and args' do + it 'names the job so a worker can resolve it from the serialized string' do + job = AsyncNamespace::DeliverService.servus_job_class + + expect('AsyncNamespace::DeliverServiceJob'.constantize).to eq(job) + end + + it 'eagerly generates the job when a named service class is defined' do + stub_const('EagerlyDefined', Module.new) + # String eval so the `class` keyword nests under EagerlyDefined and the + # subclass has its name at `inherited` time (the production eager-load path). + EagerlyDefined.module_eval('class Service < AsyncFixtureService; end', __FILE__, __LINE__) + + expect(EagerlyDefined.const_defined?(:ServiceJob, false)).to be(true) + expect(EagerlyDefined::ServiceJob.servus_service).to eq(EagerlyDefined::Service) + end + + it 'enqueues the service’s named job with the service arguments' do allow(job_class).to receive(:perform_later).and_call_original - DummyService.call_async(foo: 'bar', baz: 123) + AsyncEmailService.call_async( + foo: 'bar', + baz: 123 + ) - expect(job_class) - .to have_received(:perform_later) - .with( - name: 'DummyService', - args: { foo: 'bar', baz: 123 } - ) + expect(job_class).to have_received(:perform_later).with( + foo: 'bar', + baz: 123 + ) end it 'respects ActiveJob options like queue and priority' do allow(job_class).to receive(:set).and_call_original - DummyService.call_async( + AsyncEmailService.call_async( foo: 'a', bar: 'b', queue: :low_priority, @@ -53,37 +61,35 @@ def call job_options: { some_meta: 'test' } ) - expect(job_class) - .to have_received(:set) - .with( - priority: 10, - some_meta: 'test', - queue: :low_priority - ) + expect(job_class).to have_received(:set).with( + priority: 10, + some_meta: 'test', + queue: :low_priority + ) end it 'filters out ActiveJob-specific keys from service args' do allow(job_class).to receive(:set).and_return(job_class) allow(job_class).to receive(:perform_later).and_call_original - DummyService.call_async( + AsyncEmailService.call_async( foo: 'X', bar: 'Y', wait: 5.minutes, job_options: { debug: true } ) - expect(job_class) - .to have_received(:perform_later) - .with(name: 'DummyService', args: { foo: 'X', bar: 'Y' }) - .once + expect(job_class).to have_received(:perform_later).with( + foo: 'X', + bar: 'Y' + ).once end it 'raises JobEnqueueError if job enqueueing fails' do allow(job_class).to receive(:perform_later).and_raise(StandardError, 'Simulated failure') expect do - DummyService.call_async(test: 'data') + AsyncEmailService.call_async(test: 'data') end.to raise_error(Servus::Extensions::Async::Errors::JobEnqueueError, /Failed to enqueue async job/) end end diff --git a/gem/spec/servus/extensions/async/dsl_spec.rb b/gem/spec/servus/extensions/async/dsl_spec.rb new file mode 100644 index 0000000..912c822 --- /dev/null +++ b/gem/spec/servus/extensions/async/dsl_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require 'servus/extensions/async/ext' + +RSpec.describe '.async DSL', type: :job do + before { Servus::Base.extend(Servus::Extensions::Async::Call) } + + it 'routes the job to the given queue' do + AsyncQueueService.async(queue: :critical) + + expect(AsyncQueueService.servus_job_class.new.queue_name).to eq('critical') + end + + it 'sets the job priority' do + AsyncPriorityService.async(priority: 10) + + expect(AsyncPriorityService.servus_job_class.new.priority).to eq(10) + end + + it 'evaluates the block in the job class context' do + AsyncBlockService.async do + queue_as :from_block + end + + expect(AsyncBlockService.servus_job_class.new.queue_name).to eq('from_block') + end + + it 'applies retry_on declared inside the block' do + stub_const('BoomError', Class.new(StandardError)) + + AsyncRetryService.async do + retry_on BoomError, attempts: 5 + end + + handled = AsyncRetryService.servus_job_class.rescue_handlers.map(&:first) + expect(handled).to include('BoomError') + end + + it 'returns the configured job class' do + expect(AsyncQueueService.async(queue: :default)).to eq(AsyncQueueService.servus_job_class) + end +end diff --git a/gem/spec/servus/extensions/async/job_spec.rb b/gem/spec/servus/extensions/async/job_spec.rb index 7b734d0..9babf93 100644 --- a/gem/spec/servus/extensions/async/job_spec.rb +++ b/gem/spec/servus/extensions/async/job_spec.rb @@ -6,34 +6,23 @@ require 'servus/extensions/async/ext' RSpec.describe Servus::Extensions::Async::Job, type: :job do - # Include error modules for easier testing - let(:errors) { Servus::Extensions::Async::Errors } + before { Servus::Base.extend(Servus::Extensions::Async::Call) } - before do - stub_const('DummyService', Class.new(Servus::Base) do - def initialize(arg1:, arg2:) - super() - @a = arg1 - @b = arg2 - end + let(:job) { AsyncEmailService.servus_job_class.new } - def call - success("#{@a}, #{@b}") - end - end) - end - - let(:job) { described_class.new } - - it 'calls the correct service with given arguments' do - expect(DummyService).to receive(:call).with(a: 1, b: 2) + it 'invokes its bound service with the given arguments' do + expect(AsyncEmailService).to receive(:call).with( + a: 1, + b: 2 + ) - job.perform(name: 'DummyService', args: { a: 1, b: 2 }) + job.perform( + a: 1, + b: 2 + ) end - it 'raises NameError if the service class does not exist' do - expect do - job.perform(name: 'NonExistentService', args: {}) - end.to raise_error(errors::ServiceNotFoundError, /Service class 'NonExistentService' not found/) + it 'carries a reference back to the service it runs' do + expect(AsyncEmailService.servus_job_class.servus_service).to eq(AsyncEmailService) end end diff --git a/gem/spec/spec_support/test_services.rb b/gem/spec/spec_support/test_services.rb index c81ffcc..7f54144 100644 --- a/gem/spec/spec_support/test_services.rb +++ b/gem/spec/spec_support/test_services.rb @@ -33,3 +33,37 @@ class UserCreatedEvent < Servus::Event invoke ServiceA end + +# --- Async extension fixtures ------------------------------------------------ +# +# Real, top-level service constants so the async extension generates stable, +# uniquely-named job classes (e.g. AsyncEmailService -> AsyncEmailServiceJob) +# that persist for the whole suite — no per-example constant juggling. Each +# `.async(...)` fixture is dedicated to a single example so configuring its job +# class can't leak into others. +class AsyncFixtureService < Servus::Base + def initialize(**args) + super() + @args = args + end + + def call + success(@args) + end +end + +# Each fixture below is dedicated to a single concern so configuring its +# generated job class (queue, priority, retries) can't leak between examples: +# AsyncEmailService drives call_async + job perform; AsyncQueueService, +# AsyncPriorityService, AsyncBlockService and AsyncRetryService each exercise one +# facet of the `.async` DSL. +class AsyncEmailService < AsyncFixtureService; end +class AsyncQueueService < AsyncFixtureService; end +class AsyncPriorityService < AsyncFixtureService; end +class AsyncBlockService < AsyncFixtureService; end +class AsyncRetryService < AsyncFixtureService; end + +# Namespaced fixture — its sibling job is AsyncNamespace::DeliverServiceJob. +module AsyncNamespace + class DeliverService < AsyncFixtureService; end +end diff --git a/site/features/async-execution.md b/site/features/async-execution.md index 391d49c..d086db2 100644 --- a/site/features/async-execution.md +++ b/site/features/async-execution.md @@ -77,9 +77,47 @@ The service itself doesn't know or care whether it was called synchronously or a This means you can develop and test a service synchronously — fast feedback, easy debugging — and then switch a call site to `.call_async` when you're ready to move it to the background. Nothing inside the service changes. +## A named job per service + +Servus generates a dedicated ActiveJob class for each service, named after it. For `Treasury::TransferGold::Service` the job is `Treasury::TransferGold::ServiceJob` — a sibling constant in the service's namespace. This means your background dashboard (Sidekiq, GoodJob, …) shows a meaningful, per-service job name instead of one generic class for every job in the system, so per-queue metrics, retries, and log filtering all line up with the service that actually ran. + +You never write or reference these classes yourself — they're created for you when the service is defined. + +## Per-service job configuration + +Use the `async` class method to configure a service's job — queue, priority, retries, and anything else ActiveJob supports: + +```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 +``` + +`queue:` and `priority:` are keyword shortcuts for the two most common options. The block is evaluated in the job class's own context, so anything you'd normally write in an ActiveJob subclass — `retry_on`, `discard_on`, `around_perform`, and friends — works exactly as it does there. + +These settings are the job's class-level defaults. Options passed inline to `call_async` are layered on top per enqueue, so an inline `queue:` still wins for that one call: + +```ruby +# Runs on :critical by default (from the async block above)… +Treasury::TransferGold::Service.call_async(from_account: 1, to_account: 2, gold_dragons: 50) + +# …but this one call is routed to :low_priority instead +Treasury::TransferGold::Service.call_async( + from_account: 1, + to_account: 2, + gold_dragons: 50, + queue: :low_priority +) +``` + ## How it works -`call_async` enqueues a `Servus::Extensions::Async::Job` that stores the service class name and arguments. When the worker picks it up, it calls `Service.call(**args)` — the full lifecycle runs exactly as if you had called `.call` directly. +`call_async` enqueues the service's named job with just the service arguments — the job class itself already identifies which service to run. When the worker picks it up, it calls `Service.call(**args)`, and the full lifecycle runs exactly as if you had called `.call` directly. ```ruby args = { from_account: 1, to_account: 2, gold_dragons: 50 } @@ -89,6 +127,10 @@ Treasury::TransferGold::Service.call(**args) Treasury::TransferGold::Service.call_async(**args) ``` +::: warning Workers must eager-load +Because a job is serialized by its class name, the worker process has to be able to resolve that name. Servus defines each service's job when the service class loads, so under Rails' production eager-loading (the default) every job exists at boot. If you run workers with eager-loading off, make sure the service gets referenced before its jobs run. +::: + ## Error behavior Business failures (`failure(...)`) don't trigger ActiveJob retries — the job completes successfully, it just returns a failure `Response`. Since there's no caller waiting for the response, failures are visible through logs and events.