activejob-later 2026.10.29.162056

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 72ac0154c0607d1f6dd6f7cf36e0398062381ab252deab36ee96cfe06c9a3d6d
4
+ data.tar.gz: 42bf415d23c136a6490237a84e6cbb4987b8531fa8ee251715ce724787626467
5
+ SHA512:
6
+ metadata.gz: 37c9ebf8a59917d4e780c1b6d2b30f33aa42a69d9557c4cf268876ce53bdd83ff67c35897e7c807407588281d17edd53762c98596bb105320c0fb25e561fe724
7
+ data.tar.gz: a8433f560729ebe2514ffc0c0a1325c1fb5b261c85f2ea60e6254407edd91fbdb6df7002fbd3f7c3cdbb4843e1f17420df4da707451b59bee80a6760efb18d93
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fluence
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # activejob-later
2
+
3
+ Run any method of any class, module or instance in the background through
4
+ Active Job — no dedicated job class needed.
5
+
6
+ ```ruby
7
+ Report.perform_later.compute("2026-06")
8
+ Report.perform_later(queue: :low, wait: 10.minutes).compute("2026-06")
9
+ invoice.perform_later.generate_pdf # instances via GlobalID
10
+ Maintenance.perform_later.purge # modules too
11
+ ```
12
+
13
+ The direct call (`Report.compute(...)`) stays synchronous and untouched.
14
+ Options are passed straight to `ActiveJob::Base.set` (`queue:`, `wait:`,
15
+ `wait_until:`, `priority:`).
16
+
17
+ ## Per-class and per-method job configuration
18
+
19
+ `perform_later` calls go through a generic job by default. Declare a dedicated
20
+ job to use the full Active Job class DSL — `retry_on`, `discard_on`,
21
+ `rescue_from`, `queue_as`, callbacks, and adapter extensions such as
22
+ `limits_concurrency` (Solid Queue):
23
+
24
+ ```ruby
25
+ class Report
26
+ perform_later_job do # applies to the whole class
27
+ queue_as :reports
28
+ retry_on Timeout::Error, attempts: 5
29
+ end
30
+
31
+ perform_later_job :compute do # applies to one method only
32
+ limits_concurrency to: 1, key: ->(target, *) { target }
33
+ discard_on ActiveRecord::RecordNotFound
34
+ end
35
+
36
+ def self.compute(month)
37
+ # ...
38
+ end
39
+ end
40
+ ```
41
+
42
+ Job resolution order: method job (`Report::ComputePerformLaterJob`) → class
43
+ job (`Report::PerformLaterJob`) → generic (`ActiveJob::PerformLater::Job`).
44
+ Method jobs inherit the class job, so rules cumulate.
45
+ Declare the class-level `perform_later_job` before the per-method ones — a method job only inherits a class job that already exists at that point.
46
+
47
+ When a class method and an instance method share a name, scope the job:
48
+
49
+ ```ruby
50
+ perform_later_job :sync, on: :class do ... end # Report.perform_later.sync
51
+ perform_later_job :sync, on: :instance do ... end # report.perform_later.sync
52
+ ```
53
+
54
+ ## Building a job without enqueuing it
55
+
56
+ `job_later` takes the same options and returns the job `perform_later` would
57
+ enqueue, without enqueuing it — for APIs that take a job instance, such as
58
+ Solid Queue batch callbacks:
59
+
60
+ ```ruby
61
+ SolidQueue::Batch.enqueue(on_finish: import.job_later.finalize!) do
62
+ import.lines.each { |line| line.perform_later.process! }
63
+ end
64
+ ```
65
+
66
+ Job resolution and the errors below are the same as for `perform_later`.
67
+
68
+ ## Configuration
69
+
70
+ ```ruby
71
+ ActiveJob::PerformLater.base_job = "ApplicationJob" # default: "ActiveJob::Base"
72
+ ```
73
+
74
+ Parent class of the jobs generated by `perform_later_job`.
75
+
76
+ ## Error behavior
77
+
78
+ - Unknown or private method → `NoMethodError` at enqueue time, not in the worker.
79
+ - Non-serializable instance (no GlobalID, no custom serializer) →
80
+ `ActiveJob::SerializationError` at enqueue time.
81
+ - Target class removed before the job runs → `NameError` at perform.
82
+ - The proxy forwards every method call, so reflective calls like `.class` on the proxy itself enqueue real jobs; `inspect` is the exception (safe to display in a console).
83
+ - String instances are rejected as targets (`ArgumentError`) — a String would be ambiguous with a class reference in the job payload.
84
+ - Passing a block to a proxied call raises `ArgumentError` — blocks cannot be serialized.
85
+
86
+ ## Requirements
87
+
88
+ Ruby >= 3.3, activejob >= 7.1.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module PerformLater
5
+ # Shared perform implementation included in every job the gem
6
+ # resolves or generates.
7
+ #
8
+ # @api private
9
+ module JobBehavior
10
+ # Replays the captured call: resolves a class or module reference
11
+ # back to its constant, then public_sends the method.
12
+ #
13
+ # @api private
14
+ # @param target [Object, String] receiver, or class/module name
15
+ # @param method_name [String] method to invoke
16
+ # @param args [Array] positional arguments
17
+ # @param kwargs [Hash] keyword arguments
18
+ # @return [Object] whatever the replayed method returns
19
+ def perform(target, method_name, args, kwargs)
20
+ target = ::Object.const_get(target) if target.is_a?(String)
21
+ target.public_send(method_name, *args, **kwargs)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ ActiveSupport.on_load(:active_job) do
28
+ # Generic fallback job used when no dedicated job constant matches the
29
+ # enqueued method. Defined in the load hook so {PerformLater.base_job}
30
+ # is only touched once Active Job is actually loaded.
31
+ class ActiveJob::PerformLater::Job < ActiveJob::Base
32
+ include ActiveJob::PerformLater::JobBehavior
33
+ end
34
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module PerformLater
5
+ # `perform_later_job` class macro, mixed into Module.
6
+ module Macro
7
+ # Accepted values for the `on:` scope option.
8
+ #
9
+ # @api private
10
+ # @return [Array<Symbol, nil>] valid scopes
11
+ VALID_SCOPES = [nil, :class, :instance].freeze
12
+
13
+ # Defines a dedicated job class for `method_name` — or the
14
+ # class-wide `PerformLaterJob` fallback when no name is given —
15
+ # named after {PerformLater.job_const_name} and inheriting from the
16
+ # nearest `PerformLaterJob` or {PerformLater.base_job}.
17
+ #
18
+ # @api public
19
+ # @param method_name [Symbol, nil] method the job is dedicated to;
20
+ # nil declares the class-wide fallback job
21
+ # @param on [Symbol, nil] restrict the job to `:class` or
22
+ # `:instance` calls of `method_name`
23
+ # @yield optional block class_eval'ed inside the generated job
24
+ # (`queue_as`, `retry_on`, ...)
25
+ # @return [Class] the generated job class
26
+ # @raise [ArgumentError] on an invalid scope, a scope without a
27
+ # method name, or an already-defined job constant
28
+ # @example Dedicated job with options
29
+ # class Report
30
+ # perform_later_job :generate, on: :instance do
31
+ # queue_as :reports
32
+ # end
33
+ # end
34
+ def perform_later_job(method_name = nil, on: nil, &block)
35
+ unless VALID_SCOPES.include?(on)
36
+ raise ArgumentError, "on: must be :class or :instance, got #{on.inspect}"
37
+ end
38
+ raise ArgumentError, "on: requires a method name" if on && method_name.nil?
39
+
40
+ const_name = PerformLater.job_const_name(method_name, on: on)
41
+ if const_defined?(const_name, false)
42
+ raise ArgumentError, "#{self}::#{const_name} is already defined"
43
+ end
44
+
45
+ parent =
46
+ if method_name
47
+ PerformLater.lookup_const(self, "PerformLaterJob") || PerformLater.resolved_base_job
48
+ else
49
+ PerformLater.resolved_base_job
50
+ end
51
+
52
+ job = const_set(const_name, Class.new(parent))
53
+ job.include(JobBehavior)
54
+ job.class_eval(&block) if block
55
+ job
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module PerformLater
5
+ # `perform_later` entry point, mixed into Object.
6
+ module Mixin
7
+ # Returns a proxy that enqueues the next method call as an Active
8
+ # Job instead of invoking it.
9
+ #
10
+ # @api public
11
+ # @param options [Hash] Active Job set options (`:queue`, `:wait`,
12
+ # `:wait_until`, `:priority`, ...)
13
+ # @return [Proxy] proxy enqueueing method calls on this receiver
14
+ # @example
15
+ # Newsletter.perform_later(queue: :low).deliver_all
16
+ def perform_later(**options)
17
+ Proxy.new(self, options)
18
+ end
19
+
20
+ # Returns a proxy that builds the next method call as an Active Job
21
+ # without enqueuing it, resolved and checked like {#perform_later}.
22
+ #
23
+ # @api public
24
+ # @param options [Hash] Active Job set options (`:queue`, `:wait`,
25
+ # `:wait_until`, `:priority`, ...)
26
+ # @return [Proxy] proxy building jobs for method calls on this receiver
27
+ # @example Hand a call to a Solid Queue batch callback
28
+ # SolidQueue::Batch.enqueue(on_finish: import.job_later.finalize!) { ... }
29
+ def job_later(**options) = Proxy.new(self, options, enqueue: false)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module PerformLater
5
+ # Intermediate object returned by {Mixin#perform_later} and
6
+ # {Mixin#job_later}: captures the next method call and enqueues — or
7
+ # only builds — it instead of invoking it.
8
+ #
9
+ # @api private
10
+ class Proxy < BasicObject
11
+ # @api private
12
+ # @param target [Object] receiver the call will be replayed on
13
+ # @param options [Hash] Active Job set options
14
+ # @param enqueue [Boolean] false to build the job without enqueuing it
15
+ # @return [Proxy] a new proxy around target
16
+ def initialize(target, options, enqueue: true)
17
+ @target = target
18
+ @options = options
19
+ @enqueue = enqueue
20
+ end
21
+
22
+ # Enqueues — or only builds, for {Mixin#job_later} — `method_name`
23
+ # on the captured target through the job class resolved by
24
+ # {PerformLater.job_for}.
25
+ #
26
+ # @api private
27
+ # @return [ActiveJob::Base] the enqueued, or built, job instance
28
+ # @raise [NoMethodError] when the target does not respond to the
29
+ # method
30
+ # @raise [ArgumentError] when a block is passed — blocks are not
31
+ # serializable
32
+ def method_missing(method_name, *args, **kwargs, &block)
33
+ unless @target.respond_to?(method_name)
34
+ ::Kernel.raise ::NoMethodError,
35
+ "undefined method '#{method_name}' for #{@target.inspect} (via perform_later)"
36
+ end
37
+
38
+ if block
39
+ ::Kernel.raise ::ArgumentError,
40
+ "cannot enqueue '#{method_name}' with a block — blocks are not serializable"
41
+ end
42
+
43
+ job = PerformLater.job_for(@target, method_name)
44
+ arguments = [PerformLater.target_ref(@target), method_name.to_s, args, kwargs]
45
+ return job.new(*arguments).set(@options) unless @enqueue
46
+
47
+ job.set(**@options).perform_later(*arguments)
48
+ end
49
+
50
+ # Human-readable description of the proxy.
51
+ #
52
+ # @api private
53
+ # @return [String] the captured target and options
54
+ def inspect
55
+ "#<ActiveJob::PerformLater::Proxy target=#{@target.inspect} options=#{@options.inspect}>"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module PerformLater
5
+ # Semantic version of the gem, bumped by the release workflow.
6
+ #
7
+ # @api public
8
+ # @return [String] the current version
9
+ VERSION = '2026.10.29.162056'
10
+ end
11
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/perform_later/version"
4
+ require "active_job/perform_later/job"
5
+ require "active_job/perform_later/proxy"
6
+ require "active_job/perform_later/mixin"
7
+ require "active_job/perform_later/macro"
8
+
9
+ module ActiveJob
10
+ # Runs any method of any class or instance in the background, either
11
+ # through ad-hoc {Mixin#perform_later} proxies or through dedicated
12
+ # jobs declared with the {Macro#perform_later_job} macro.
13
+ #
14
+ # @example Enqueue a method call on a model
15
+ # user.perform_later(wait: 5.minutes).send_welcome_email
16
+ module PerformLater
17
+ class << self
18
+ # Sets the job class every generated job inherits from.
19
+ #
20
+ # Accepts a Class, or a String resolved lazily so the constant can
21
+ # be defined after configuration runs (e.g. an autoloaded
22
+ # `ApplicationJob`).
23
+ #
24
+ # @api public
25
+ # @return [Class, String] the new base job
26
+ attr_writer :base_job
27
+
28
+ # The job class (or class name) generated jobs inherit from.
29
+ #
30
+ # @api public
31
+ # @return [Class, String] defaults to `"ActiveJob::Base"`
32
+ def base_job
33
+ @base_job ||= "ActiveJob::Base"
34
+ end
35
+
36
+ # Resolves {base_job} to a Class, constantizing it when configured
37
+ # as a String.
38
+ #
39
+ # @api private
40
+ # @return [Class] the job class generated jobs inherit from
41
+ def resolved_base_job
42
+ base_job.is_a?(String) ? Object.const_get(base_job) : base_job
43
+ end
44
+
45
+ # Converts a proxy target into an Active Job serializable reference.
46
+ #
47
+ # Classes and modules are passed by name; any other object is
48
+ # passed as-is and relies on GlobalID (or primitive) serialization.
49
+ #
50
+ # @api private
51
+ # @param target [Object] receiver captured by the proxy
52
+ # @return [Object] the reference given to `perform_later`
53
+ # @raise [ArgumentError] when target is a String or an anonymous
54
+ # class or module
55
+ def target_ref(target)
56
+ if target.is_a?(String)
57
+ raise ArgumentError, "String targets are not supported by perform_later"
58
+ end
59
+ return target unless target.is_a?(Module)
60
+
61
+ target.name || raise(ArgumentError, "cannot perform_later on an anonymous class or module")
62
+ end
63
+
64
+ # Picks the job class used to enqueue `method_name` on `target`.
65
+ #
66
+ # Looks up, in order: the scope-prefixed constant
67
+ # (`Class…`/`Instance…`), the method-named constant, the class-wide
68
+ # `PerformLaterJob`, then falls back to the generic {Job}.
69
+ #
70
+ # @api private
71
+ # @param target [Object] receiver captured by the proxy
72
+ # @param method_name [Symbol] method being enqueued
73
+ # @return [Class] the Active Job class to enqueue with
74
+ def job_for(target, method_name)
75
+ klass = target.is_a?(Module) ? target : target.class
76
+ scope = target.is_a?(Module) ? :class : :instance
77
+
78
+ [job_const_name(method_name, on: scope),
79
+ job_const_name(method_name),
80
+ "PerformLaterJob"].each do |const_name|
81
+ job = lookup_const(klass, const_name)
82
+ return job if job
83
+ end
84
+
85
+ Job
86
+ end
87
+
88
+ # Builds the conventional job constant name for a method.
89
+ #
90
+ # @api private
91
+ # @param method_name [Symbol, String, nil] method the job wraps
92
+ # @param on [Symbol, nil] optional `:class` / `:instance` scope prefix
93
+ # @return [String] e.g. `"SyncPerformLaterJob"`,
94
+ # `"ClassSyncPerformLaterJob"`
95
+ def job_const_name(method_name, on: nil)
96
+ return "PerformLaterJob" if method_name.nil?
97
+
98
+ prefix = on ? ActiveSupport::Inflector.camelize(on.to_s) : ""
99
+ base = ActiveSupport::Inflector.camelize(method_name.to_s.sub(/[?!=]\z/, ""))
100
+ "#{prefix}#{base}PerformLaterJob"
101
+ end
102
+
103
+ # Finds `const_name` defined directly on `mod` or one of its
104
+ # ancestors, skipping Object so a top-level constant does not leak
105
+ # into every class.
106
+ #
107
+ # @api private
108
+ # @param mod [Module] starting point of the ancestry lookup
109
+ # @param const_name [String] constant name to look up
110
+ # @return [Class, nil] the matching job class, or nil when undefined
111
+ def lookup_const(mod, const_name)
112
+ mod.ancestors.each do |ancestor|
113
+ next if ancestor == Object
114
+ return ancestor.const_get(const_name, false) if ancestor.const_defined?(const_name, false)
115
+ end
116
+ nil
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ Object.include ActiveJob::PerformLater::Mixin
123
+ Module.include ActiveJob::PerformLater::Macro
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "active_job/perform_later"
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activejob-later
3
+ version: !ruby/object:Gem::Version
4
+ version: 2026.10.29.162056
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan PHILIPPE
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activejob
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ executables: []
27
+ extensions: []
28
+ extra_rdoc_files: []
29
+ files:
30
+ - LICENSE
31
+ - README.md
32
+ - lib/active_job/perform_later.rb
33
+ - lib/active_job/perform_later/job.rb
34
+ - lib/active_job/perform_later/macro.rb
35
+ - lib/active_job/perform_later/mixin.rb
36
+ - lib/active_job/perform_later/proxy.rb
37
+ - lib/active_job/perform_later/version.rb
38
+ - lib/activejob-later.rb
39
+ homepage: https://github.com/fluence-eu/activejob-later
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ homepage_uri: https://github.com/fluence-eu/activejob-later
44
+ source_code_uri: https://github.com/fluence-eu/activejob-later
45
+ changelog_uri: https://github.com/fluence-eu/activejob-later/blob/main/CHANGELOG.md
46
+ github_repo: https://github.com/fluence-eu/activejob-later
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.3'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 4.0.20
63
+ specification_version: 4
64
+ summary: Run any method of any class in the background via Active Job
65
+ test_files: []