Module: Wurk::Unique
- Defined in:
- lib/wurk/unique.rb
Overview
Sidekiq Enterprise unique jobs. Best-effort dedup at enqueue time keyed
by a SHA256 digest of [class, queue, args] (overridable via
sidekiq_unique_context; ActiveJob-wrapped payloads narrow to the wrapped
class + its arguments — see active_job_context). Three lock-release
strategies:
* `unique_until: :success` (default) — lock retained through retries;
server middleware DELs it on successful perform. Surviving across
a process crash is bounded by `unique_for` TTL.
* `unique_until: :start` — server middleware DELs the lock right
*before* invoking perform; a duplicate can be enqueued while the
first is running.
A job that dies automatically (retries exhausted / discarded) releases its lock via a death handler; manual UI kills keep the lock until TTL expiry (Ent wiki, Ent-Unique-Jobs).
Wire-compat (§3.9): single-key Redis layout — unique:<sha256> STRING
holding the owning JID. Scheduled jobs extend the TTL by the delay so
the lock covers the entire wait+execution window (§3.4).
This is the native replacement for the sidekiq-unique-jobs gem; see the
migration guide for the lock: → unique_until: translation table.
Spec: docs/target/sidekiq-ent.md §3.
Defined Under Namespace
Classes: ClientMiddleware, ConfigurationError, ServerMiddleware
Constant Summary collapse
- KEY_PREFIX =
'unique:'- DEFAULT_UNTIL =
:success- VALID_UNTIL =
%i[success start].freeze
- ACTIVE_JOB_WRAPPERS =
Class names that carry an ActiveJob payload as their single arg. The canonical one Wurk writes is
Sidekiq::ActiveJob::Wrapper; the others appear in payloads enqueued by older Sidekiq/Rails versions still sitting in Redis at gem-swap time. [ 'Sidekiq::ActiveJob::Wrapper', 'Wurk::ActiveJob::Wrapper', 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper', 'ActiveJob::QueueAdapters::WurkAdapter::JobWrapper' ].freeze
- DEATH_HANDLER =
Ent parity: a job that dies automatically releases its lock so a duplicate can enqueue immediately. Manual API/UI kills keep the lock until TTL expiry (Ent wiki, Ent-Unique-Jobs) — they reach death handlers too (Sidekiq fires them on API kills), so we recognize the synthesized kill exception and skip the release for it. Atomic CAS-DEL via the shared Lua script mirrors ServerMiddleware#release.
lambda do |job, exception| next unless Wurk::Unique.enabled? next unless Wurk::Unique.coerce_ttl(job['unique_for']) next if exception.instance_of?(::RuntimeError) && exception. == DeadSet::API_KILL_MESSAGE Wurk.redis { |conn| Wurk::Unique.release_if_owner(conn, Wurk::Unique.lock_key_for(job), job['jid']) } end
- NUMERIC_TTL =
Duration#to_s: "300" for
5.minutes, "90.0" for1.5.minutes. /\A\d+(?:\.\d+)?\z/
Class Method Summary collapse
-
.coerce_ttl(value) ⇒ Object
Coerce
unique_forto a numeric seconds value. -
.digest_for(job) ⇒ Object
The identity digest on its own, without the
unique:prefix. -
.disable! ⇒ Object
Test helper — not part of the public Sidekiq surface.
- .duration_like?(value) ⇒ Boolean
-
.enable!(config = Wurk.configuration) ⇒ Object
configselects the chains and death handlers, installed once each; the enabled flag remains process-global, including for other configs whose chains already carry the middleware. - .enabled? ⇒ Boolean
-
.lock_key(klass, queue, args) ⇒ Object
Compute the lock key for an arbitrary
(queue, klass, args)triple, bypassingsidekiq_unique_context. -
.lock_key_for(job) ⇒ Object
Compute the lock key from a job payload, honoring
sidekiq_unique_contextwhen the worker class is loaded and defines it. -
.locked?(queue_or_klass, klass_or_args = nil, args = nil) ⇒ String?
Owning jid, or nil when the lock is free.
-
.release_if_owner(conn, key, jid) ⇒ Object
Atomic compare-and-delete of a unique lock key.
-
.unique_context(job) ⇒ Object
Default:
[class, queue, args].
Class Method Details
.coerce_ttl(value) ⇒ Object
Coerce unique_for to a numeric seconds value. Accepts Integer,
Numeric, ActiveSupport::Duration (any to_i-respondent), a numeric
String, or false (skip). Returns nil when uniqueness should be skipped.
The String is what every reader after the push sees for a Duration:
JSON.generate writes 5.minutes as "300", in Sidekiq as here, so the
server middleware and the death handler get "300" back off the queue.
Refusing it left every unique_for: 5.minutes lock held until its TTL
(tesote/tesote.com#519).
213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/wurk/unique.rb', line 213 def self.coerce_ttl(value) return nil if value.nil? || value == false # `.to_i`, not `value`: ActiveSupport::Duration overrides `is_a?(Integer)` # to return true (it delegates to its underlying value), so `1.hour` passes # this guard — returning the raw Duration handed redis-client a non-Integer # EX arg and raised TypeError at enqueue (#253). return value.to_i if value.is_a?(Integer) && value.positive? return value.to_i if value.is_a?(Numeric) return value.to_f.to_i if value.is_a?(String) && value.match?(NUMERIC_TTL) return value.to_i if duration_like?(value) nil end |
.digest_for(job) ⇒ Object
The identity digest on its own, without the unique: prefix.
Debounce keys off the same digest under its own prefix, so a
class that narrows its identity via sidekiq_unique_context narrows
both policies through the one documented hook instead of growing a
second, near-identical one.
137 138 139 |
# File 'lib/wurk/unique.rb', line 137 def digest_for(job) Digest::SHA256.hexdigest(JSON.dump(unique_context(job))) end |
.disable! ⇒ Object
Test helper — not part of the public Sidekiq surface. Clears the flag so per-test enable!/disable! does not leak across runs.
112 113 114 115 |
# File 'lib/wurk/unique.rb', line 112 def disable! @enabled = false nil end |
.duration_like?(value) ⇒ Boolean
230 231 232 233 234 |
# File 'lib/wurk/unique.rb', line 230 def self.duration_like?(value) return false unless value.respond_to?(:to_i) value.respond_to?(:since) || value.class.name.to_s.include?('Duration') end |
.enable!(config = Wurk.configuration) ⇒ Object
config selects the chains and death handlers, installed once each;
the enabled flag remains process-global, including for other configs
whose chains already carry the middleware.
104 105 106 107 108 |
# File 'lib/wurk/unique.rb', line 104 def enable!(config = Wurk.configuration) @enabled = true register_middleware!(config) true end |
.enabled? ⇒ Boolean
97 98 99 |
# File 'lib/wurk/unique.rb', line 97 def enabled? @enabled == true end |
.lock_key(klass, queue, args) ⇒ Object
Compute the lock key for an arbitrary (queue, klass, args) triple,
bypassing sidekiq_unique_context. Public so operators can compute
the key of a worker that uses the default context (docs §8).
120 121 122 123 |
# File 'lib/wurk/unique.rb', line 120 def lock_key(klass, queue, args) context = [klass.to_s, queue.to_s, args] "#{KEY_PREFIX}#{Digest::SHA256.hexdigest(JSON.dump(context))}" end |
.lock_key_for(job) ⇒ Object
Compute the lock key from a job payload, honoring
sidekiq_unique_context when the worker class is loaded and
defines it.
128 129 130 |
# File 'lib/wurk/unique.rb', line 128 def lock_key_for(job) "#{KEY_PREFIX}#{digest_for(job)}" end |
.locked?(queue_or_klass, klass_or_args = nil, args = nil) ⇒ String?
Returns owning jid, or nil when the lock is free.
241 242 243 244 245 246 247 248 |
# File 'lib/wurk/unique.rb', line 241 def self.locked?(queue_or_klass, klass_or_args = nil, args = nil) queue, klass, payload = normalize_locked_args(queue_or_klass, klass_or_args, args) # Routed through lock_key_for, not lock_key: the probe must honor # `sidekiq_unique_context` (and the ActiveJob default) or it reports # "free" for every worker that customizes its digest. key = lock_key_for('class' => klass.to_s, 'queue' => queue.to_s, 'args' => payload) Wurk.redis { |c| c.call('GET', key) } end |
.release_if_owner(conn, key, jid) ⇒ Object
Atomic compare-and-delete of a unique lock key. Two-command
GET-then-DEL is not a real CAS — the key can expire between the GET
and DEL, letting a fresh enqueue grab it, and the bare DEL would
then drop the new owner's lock. Routed through a single Lua script
(Wurk::Lua::RELEASE_IF_OWNER) shared by ServerMiddleware#release
(normal success/start release) and DEATH_HANDLER (automatic-death
release) so the two paths cannot drift.
88 89 90 |
# File 'lib/wurk/unique.rb', line 88 def self.release_if_owner(conn, key, jid) Wurk::Lua::Loader.eval_cached(conn, :release_if_owner, keys: [key], argv: [jid]) end |
.unique_context(job) ⇒ Object
Default: [class, queue, args]. Workers may override by defining
self.sidekiq_unique_context(job) returning any JSON-serializable
value (e.g. a subset of args). Spec §3.5. ActiveJob-wrapped payloads
get a narrowed default (see active_job_context) because their raw
args can never repeat.
146 147 148 149 150 151 |
# File 'lib/wurk/unique.rb', line 146 def unique_context(job) klass = resolve_class(job['class']) return klass.sidekiq_unique_context(job) if klass.respond_to?(:sidekiq_unique_context) active_job_context(job) || [job['class'], job['queue'], job['args']] end |