Module: Pgbus::Uniqueness
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/pgbus/uniqueness.rb
Overview
Job uniqueness guarantees: prevent duplicate jobs from running concurrently.
Unlike concurrency limits (which allow N concurrent jobs for the same key), uniqueness ensures AT MOST ONE job with a given key exists in the system at any time — from enqueue through completion.
Lock lifecycle (advisory lock + thin lookup table):
1. Enqueue: pg_advisory_xact_lock serializes concurrent attempts,
then INSERT INTO pgbus_uniqueness_keys ON CONFLICT DO NOTHING.
The lock row lives as long as the job is in the queue or executing.
2. Execution: PGMQ's visibility timeout is the execution lock —
no separate claim_for_execution step needed.
3. Completion/DLQ: DELETE FROM pgbus_uniqueness_keys WHERE lock_key = ?.
4. Crash recovery: if a worker dies, VT expires, the message becomes
readable again. The uniqueness key row stays (correctly — the job
hasn't finished). The next worker picks it up and executes.
Strategies:
:until_executed — Lock acquired at enqueue, held through execution, released on
completion or DLQ. Prevents duplicate enqueue AND duplicate execution.
:while_executing — Lock acquired at execution start, released on completion.
Allows duplicate enqueue (multiple copies in queue) but only one
executes at a time.
Usage:
class ImportOrderJob < ApplicationJob
ensures_uniqueness strategy: :until_executed,
key: ->(order_id) { "import-order-#{order_id}" },
on_conflict: :reject
def perform(order_id)
# Only one instance of this job per order_id can exist at a time
end
end
Constant Summary collapse
- METADATA_KEY =
"pgbus_uniqueness_key"- STRATEGY_KEY =
"pgbus_uniqueness_strategy"- VALID_STRATEGIES =
%i[until_executed while_executing].freeze
- VALID_CONFLICTS =
%i[reject discard log].freeze
Class Method Summary collapse
-
.acquire_enqueue_lock(key, active_job, queue_name: nil, msg_id: nil) ⇒ Object
Acquire the uniqueness lock at enqueue time (:until_executed only).
-
.acquire_execution_lock(key, payload) ⇒ Object
Acquire the uniqueness lock at execution time (:while_executing only).
- .extract_key(payload) ⇒ Object
- .extract_strategy(payload) ⇒ Object
-
.guard_class_name_default!(active_job, config, args) ⇒ Object
Guards the class-name default key against the per-record collapse footgun (#333).
- .inject_metadata(active_job, payload_hash) ⇒ Object
-
.release_lock(key) ⇒ Object
Release the uniqueness lock after execution completes.
- .resolve_key(active_job) ⇒ Object
- .uniqueness_config(active_job) ⇒ Object
Class Method Details
.acquire_enqueue_lock(key, active_job, queue_name: nil, msg_id: nil) ⇒ Object
Acquire the uniqueness lock at enqueue time (:until_executed only). Uses pg_advisory_xact_lock to serialize concurrent attempts. Returns :acquired, :locked, or :no_lock.
168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/pgbus/uniqueness.rb', line 168 def acquire_enqueue_lock(key, active_job, queue_name: nil, msg_id: nil) config = uniqueness_config(active_job) return :no_lock unless config return :no_lock unless config[:strategy] == :until_executed acquired = if msg_id && queue_name UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id) else # Pre-produce check: use advisory lock + ON CONFLICT UniquenessKey.acquire!(key, queue_name: queue_name || "pending", msg_id: msg_id || 0) end acquired ? :acquired : :locked end |
.acquire_execution_lock(key, payload) ⇒ Object
Acquire the uniqueness lock at execution time (:while_executing only). Returns true if acquired, false if another instance is running.
184 185 186 187 188 189 190 |
# File 'lib/pgbus/uniqueness.rb', line 184 def acquire_execution_lock(key, payload) strategy = extract_strategy(payload) return true unless strategy == :while_executing queue_name = payload["queue_name"] || "unknown" UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: 0) end |
.extract_key(payload) ⇒ Object
151 152 153 |
# File 'lib/pgbus/uniqueness.rb', line 151 def extract_key(payload) payload&.dig(METADATA_KEY) end |
.extract_strategy(payload) ⇒ Object
155 156 157 |
# File 'lib/pgbus/uniqueness.rb', line 155 def extract_strategy(payload) payload&.dig(STRATEGY_KEY)&.to_sym end |
.guard_class_name_default!(active_job, config, args) ⇒ Object
Guards the class-name default key against the per-record collapse footgun (#333). When an :until_executed job was declared with NO explicit key (so the key is the class name) AND is enqueued WITH arguments, every distinct argument set would resolve to the same class-name key and collapse into one per-class singleton — almost never what the caller wants. Raise with an actionable message. A no-argument job keeps the class-name default (one logical instance, e.g. a recurring task that must not overlap itself), and :while_executing is unaffected (it acquires per-invocation at execution start, not by class-name identity at enqueue).
126 127 128 129 130 131 132 133 134 135 136 |
# File 'lib/pgbus/uniqueness.rb', line 126 def guard_class_name_default!(active_job, config, args) return if config[:explicit_key] return unless config[:strategy] == :until_executed return if args.nil? || args.empty? raise ArgumentError, "#{active_job.class.name} uses ensures_uniqueness strategy: :until_executed with no key: " \ "but is enqueued with arguments — the default key is the class name, which would collapse " \ "every distinct argument set into one per-class singleton. Pass an explicit " \ "key: ->(*args) { ... } that includes the arguments. See https://pgbus.dev/docs/upgrading-pgbus" end |
.inject_metadata(active_job, payload_hash) ⇒ Object
138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/pgbus/uniqueness.rb', line 138 def (active_job, payload_hash) config = uniqueness_config(active_job) return payload_hash unless config key = resolve_key(active_job) return payload_hash unless key payload_hash.merge( METADATA_KEY => key, STRATEGY_KEY => config[:strategy].to_s ) end |
.release_lock(key) ⇒ Object
Release the uniqueness lock after execution completes.
193 194 195 196 197 |
# File 'lib/pgbus/uniqueness.rb', line 193 def release_lock(key) return unless key UniquenessKey.release!(key) end |
.resolve_key(active_job) ⇒ Object
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/pgbus/uniqueness.rb', line 97 def resolve_key(active_job) config = uniqueness_config(active_job) return nil unless config args = active_job.arguments guard_class_name_default!(active_job, config, args) key = if config[:explicit_key] Support.call_key_proc(config[:key], args) else # Class-name default, resolved from the ENQUEUED job's class so # an inherited declaration keys each subclass separately (#357). active_job.class.name end # Automatically serialize GlobalID-compatible objects (e.g. ActiveRecord models) # so users can pass model instances directly without manual .to_global_id.to_s key = key.to_global_id.to_s if key.respond_to?(:to_global_id) key end |
.uniqueness_config(active_job) ⇒ Object
159 160 161 162 163 |
# File 'lib/pgbus/uniqueness.rb', line 159 def uniqueness_config(active_job) return nil unless active_job.class.respond_to?(:pgbus_uniqueness) active_job.class.pgbus_uniqueness end |