class

JoobQ::RedisStore

Inherits JoobQ::Store < Reference < Object

Constants

DELAYED_SET = "joobq:delayed_jobs"
OWNERS = "joobq:processing-owners"

Each process claims into its own processing list, joobq:processing:<queue>:<owner>, and publishes a liveness heartbeat in the joobq:processing:owners sorted set (score = last beat, ms). Recovery returns a processing list to its queue only when its owner's heartbeat has lapsed, so a live process's in-flight jobs are never moved out from under it. joobq:processing:<queue> with no owner suffix is the shared list older (0.5) workers claimed into; it is migrated once no live owner remains. The heartbeat is independent of the optional WorkerRegistry, so recovery is correct whether or not a process publishes the richer worker snapshot. Not under the joobq:processing: prefix, so the processing-list SCANs never match it, and a queue literally named "owners" does not collide with it.

PROMOTE_DUE = " local score = redis.call('ZSCORE', KEYS[1], ARGV[1])\n if not score or tonumber(score) > tonumber(ARGV[3]) then return 0 end\n redis.call('RPUSH', KEYS[2], ARGV[2])\n redis.call('ZREM', KEYS[1], ARGV[1])\n return 1"

Source membership is the claim: competing schedulers cannot promote the same member twice. Write the destination first: Lua does not roll back on errors. JSON is parsed/re-encoded in Crystal, preserving Int64 values (Lua cjson uses doubles).

RECOVER_BATCH = 1000
RECOVER_PROCESSING = " local n = 0\n local limit = tonumber(ARGV[1])\n while n < limit and redis.call('LMOVE', KEYS[1], KEYS[2], 'LEFT', 'LEFT') do\n n = n + 1\n end\n return n"

Drains a processing list to the head of its queue, oldest claim first, in one atomic script, and returns the count. Each claim pushed the job onto the head of its processing list (BLMOVE/LMOVE ... LEFT LEFT, and the old BRPOPLPUSH too), so moving head to head preserves claim order: the recovered jobs run next, before the jobs still waiting. Moves up to ARGV[1] jobs from the head of a (dead owner's) processing list to the head of its queue, and returns how many it moved. Bounded so a huge leftover list does not block single-threaded Redis in one script; the caller loops until a short batch signals the list is empty. Nothing else claims from a dead owner's list, so order is preserved across batches.

REQUEUE = " if redis.call('LREM', KEYS[1], 1, ARGV[1]) == 1 then\n redis.call('RPUSH', KEYS[3], ARGV[1])\n return 1\n end\n if redis.call('LREM', KEYS[2], 1, ARGV[1]) == 1 then\n redis.call('RPUSH', KEYS[3], ARGV[1])\n return 1\n end\n return 0"

Takes the job off whichever of this process's processing lists holds it (its own owner list, or the legacy shared list a seeded or pre-0.6 job sits in) and pushes it to the tail of the queue. KEYS: self list, legacy list, queue.

REQUEUE_BATCH = 100
REQUEUE_MANY = " local n = 0\n for i = 1, #ARGV do\n local ok, removed = pcall(redis.call, 'LREM', KEYS[3 * i - 2], 1, ARGV[i])\n if not (ok and removed == 1) then\n ok, removed = pcall(redis.call, 'LREM', KEYS[3 * i - 1], 1, ARGV[i])\n end\n if ok and removed == 1 then\n if pcall(redis.call, 'LPUSH', KEYS[3 * i], ARGV[i]) then n = n + 1 end\n end\n end\n return n"

REQUEUE to the head of the queue, for many jobs in one script. KEYS are (self list, legacy list, queue) per entry, ARGV the jobs, each taken off whichever list holds it and LPUSHed in turn (so the last entry ends up at the head). An entry whose commands fail (a key of the wrong type, say) is skipped inside the script, so one bad entry neither stops the others nor produces an error reply; a pipeline would raise at the first error reply and leave the connection with unread replies.

TAKE_DUE = " local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, ARGV[2])\n for _, job in ipairs(jobs) do\n redis.call('ZREM', KEYS[1], job)\n end\n return jobs"

Constructors

from_env

The store the environment asks for: REDIS_URL (with REDIS_TLS_VERIFY and REDIS_CA_FILE) when it is set, otherwise REDIS_HOST, REDIS_PORT and REDIS_PASS. A REDIS_URL that cannot be used raises RedisEndpoint::Invalid rather than falling back to a plain connection to REDIS_HOST.

Source
instance
Source
new(host : String = ENV.fetch("REDIS_HOST", "localhost"), port : Int32 = (ENV.fetch("REDIS_PORT", "6379")).to_i, password : String | Nil = ENV["REDIS_PASS"]?, pool_size : Int32 = (ENV.fetch("REDIS_POOL_SIZE", "500")).to_i, pool_timeout : Float64 = (ENV.fetch("REDIS_POOL_TIMEOUT", "2.0")).to_f64, connect_timeout : Time::Span = (ENV.fetch("REDIS_CONNECT_TIMEOUT", "5.0")).to_f64.seconds, command_timeout : Time::Span = (ENV.fetch("REDIS_COMMAND_TIMEOUT", "5.0")).to_f64.seconds, operation_timeout : Time::Span = (ENV.fetch("REDIS_OPERATION_TIMEOUT", "5.0")).to_f64.seconds)
Source
new(*, url : String, pool_size : Int32 = (ENV.fetch("REDIS_POOL_SIZE", "500")).to_i, pool_timeout : Float64 = (ENV.fetch("REDIS_POOL_TIMEOUT", "2.0")).to_f64, verify : Bool = true, ca_file : String | Nil = nil, connect_timeout : Time::Span = (ENV.fetch("REDIS_CONNECT_TIMEOUT", "5.0")).to_f64.seconds, command_timeout : Time::Span = (ENV.fetch("REDIS_COMMAND_TIMEOUT", "5.0")).to_f64.seconds, operation_timeout : Time::Span = (ENV.fetch("REDIS_OPERATION_TIMEOUT", "5.0")).to_f64.seconds)

Connects to the server a redis:// or rediss:// URL names (see RedisEndpoint): as an ACL user when the URL has a user name, over TLS for rediss, on the database in its path. verify and ca_file control how the server certificate is checked.

Source

Class methods

verify_setting(value : String | Nil, name : String) : Bool | Nil

true/1/yes/on and false/0/no/off (any case); nil when value is unset or empty. Anything else raises, because a misspelt "off" would otherwise be read as "on".

Source

Instance methods

beat_owner
Source
blocking_claim(queue_name : String, connection : Redis) : String | Nil

Claims the oldest job, blocking up to BLOCKING_TIMEOUT, on the caller's dedicated connection (not the pool). Claims into this process's own processing list, exactly like dequeue, so recovery is unchanged. Returns nil only on an empty queue (the block timed out). A connection error — including the close used to interrupt the block on stop — is raised, not swallowed, so the dispatcher can tell "empty" from "Redis is down" and back off on the latter instead of reconnect-storming.

Source
blocking_connection

A dedicated, non-pooled connection for a queue dispatcher's blocking BLMOVE. The command timeout is kept well above the block timeout so the block itself never trips it; closing the connection interrupts a blocked call.

Source
claim_job(queue_name : String, worker_id : String, klass : Class) : String | Nil

Claims the oldest job (see dequeue)

Source
claim_jobs_batch(queue_name : String, worker_id : String, klass : Class, batch_size : Int32 = 5) : Array(String)

Claims up to batch_size jobs, oldest first (see dequeue_batch)

Source
cleanup_job(job_json : String, queue_name : String) : Nil

Removes a finished job from the processing list and counts it as processed

Source
cleanup_jobs_batch(job_jsons : Array(String), queue_name : String) : Nil

Batch job cleanup for high performance

Source
clear_queue(queue_name : String) : Nil
Source
clear_queues_batch(queue_names : Array(String)) : Nil

Optimized method to clear multiple queues in a single pipeline

Source
close

Closes current connections for either constructor. Later commands reconnect.

Source
collect_statistics_batch

Optimized connection reuse for statistics collection

Source
delete_job(job : String) : Nil
Source
dequeue(queue_name : String, klass : Class) : String | Nil

Claims the oldest job: producers RPUSH to the tail, so taking the head (BLMOVE ... LEFT LEFT, Redis 6.2+) gives first-in-first-out order. The job moves to the head of the processing list in the same atomic step, so a crash after the claim leaves it there for move_job_back_to_queue. Blocks up to BLOCKING_TIMEOUT when the queue is empty.

Source
dequeue_batch(queue_name : String, klass : Class, batch_size : Int32 = 10) : Array(String)

Claims up to batch_size jobs from the head of the queue without blocking, oldest first (the order the replies come back in). Each LMOVE is atomic on its own, like the single claim; an empty queue answers nil.

Source
desired_concurrency(queue_name : String) : NamedTuple(count: Int32, set_at: Int64) | Nil

Desired worker count shared between processes, with when it was set.

Source
drain_processing_list(list_key : String, queue_name : String) : Int64
Source
endpoint

The parsed URL of a store made with url:; nil for one made with host and port.

Source
enqueue(job : Job) : String
Source
enqueue_batch(jobs : Array(Job), batch_size : Int32 = 1000, *, json_sizes : Array(Int32) | Nil = nil) : Nil
Source
ensure_list_move_support!

Claims use LMOVE/BLMOVE (Redis 6.2+). On an older server every claim would fail and be logged while the queue grows, so a queue refuses to start instead. Asks the server itself (LMOVE on a key that does not exist moves nothing) rather than parsing a version string, so Redis-compatible servers that implement the command pass.

Source
fetch_due_jobs(current_time = Time.local, delay_set : String = DELAYED_SET, limit : Int32 = 50, remove : Bool = true) : Array(String)
Source
find_jobs_batch(jids : Array(String)) : Hash(String, String | Nil)

Simplified batch job lookup - search in all known locations

Source
get_all_queue_metrics

Get metrics for all configured queues using pipelining

Source
get_multiple_state_counts(states : Array(String)) : Hash(String, Int32)

Optimized method to get job counts for multiple states at once

Source
get_processing_jobs_count

Optimized method to get processing jobs count using Lua script

Source
get_queue_metrics(queue_name : String) : QueueMetrics

Get metrics for a single queue

Source
get_queue_metrics_pipelined(queue_names : Array(String)) : Hash(String, QueueMetrics)

Get queue metrics for multiple queues using pipelining

Source
get_retrying_jobs_count

Optimized retrying jobs count using Lua script for better performance

Source
get_retrying_jobs_paginated(page : Int32, per_page : Int32) : Array(String)

Optimized retrying jobs pagination using Lua script

Source
health
Source
health_check

Connection pool health check with detailed metrics

Source
host
Source
inspect(io : IO) : Nil

Host, port and pool only: the default inspect would print the password this store was given.

Source
list_jobs(queue_name : String, page_number : Int32 = 1, page_size : Int32 = 200) : Array(String)

A page of the waiting jobs, the next to run first (workers claim from the head of the list).

Source
list_sorted_set_jobs(set_name : String, page_number : Int32 = 1, page_size : Int32 = 200) : Array(String)
Source
live_owners

Owner ids whose heartbeat is within the TTL. Lapsed entries are pruned as they are read. Liveness is scored by the Redis clock, so recovery does not depend on the worker and reaper clocks agreeing. A process paused (GC, starvation) longer than OWNER_TTL can still be judged dead and have its jobs recovered, so jobs stay idempotent.

Source
mark_as_dead(job : Job, expiration_time : Int64) : Nil

Scored by time of death like every other dead-letter writer, so entries sort and age consistently; expiration_time is kept for the Store interface.

Source
mark_job_completed(job_json : String, queue_name : String) : Nil

Mark job as completed with statistics

Source
metrics
Source
move_job_back_to_queue(queue_name : String, include_self : Bool = false) : Bool

Reclaims the queue's orphaned (dead-owner) processing lists; never touches a live process's list. Queue#start passes include_self: true to also clear a previous incarnation's leftovers before this process claims. True when at least one job was moved; false when none, or when Redis failed (logged). Operate this on a live system only to recover crashed workers: it cannot take a job a live worker still holds.

Source
move_to_dead_letter(job : Job, queue_name : String) : Nil

High-performance move to dead letter queue using pipelined operations

Source
move_to_retry(job : Job, queue_name : String, delay_ms : Int64) : Bool

High-performance move to retry queue using pipelined operations

Source
owner_id

Identifies this process's processing lists and heartbeat. Stable for the life of the process; a crashed process that restarts gets a new pid and so a new id, which leaves its old list orphaned for recovery. Settable so a spec can act as several processes against one Redis.

Source
owner_id=(id : String) : String

Keeps the batch-cleanup pipeline on the same owner id, so a spec that poses as another process cleans up the list it claimed into.

Source
paused?(queue_name : String) : Bool

Optional capabilities. Reads are safe defaults; writes raise, so a store that cannot pause or resize queues says so instead of silently ignoring it.

Source
pipeline
Source
pool_size
Source
pool_timeout
Source
port
Source
process_due_delayed_jobs(queue_name : String) : Array(String)

Preserve the legacy return contract: all scanned due jobs, including other queues.

Source
processing_list(pattern : String = "#{PROCESSING_QUEUE}:*", limit : Int32 = 100) : Array(String)
Source
processing_list_paginated(offset : Int32, limit : Int32, pattern : String = "#{PROCESSING_QUEUE}:*") : Array(String)

Optimized processing jobs list with batch operations

Source
processing_lists(queue_name : String) : Array(String)

The processing-list keys that belong to queue_name: one per owner (...:<queue>:<owner>) plus the shared legacy list (...:<queue>) older workers used. A SCAN by prefix also matches another queue whose name starts with this one, so each key is checked against the two exact shapes.

Source
processing_queue_size(queue_name : String) : Int64

Get count of jobs currently in processing queue The jobs in processing across every owner of the queue, plus the legacy shared list.

Source
promote_due_jobs(current_time : Time = Time.utc, delay_set : String = DELAYED_SET, queue_name : String | Nil = nil, limit : Int32 = 50, *, report_scanned : Bool = false) : Array(String)
Source
queue_size(queue_name : String) : Int64
Source
queue_sizes_batch(queue_names : Array(String)) : Hash(String, Int64)

Optimized batch queue sizes to reduce connection overhead

Source
recover_orphaned_processing(queue_name : String, include_self : Bool = false) : Int64

Returns the jobs of crashed or stopped processes to the head of their queue, in claim order, and deletes the drained list. A processing list is reclaimed only when its owner's heartbeat has lapsed (live_owners), so a running process's in-flight jobs are never moved. include_self drains this process's own list too, which is safe at start-up (no worker of this process is claiming yet) but never from the periodic reaper, where this process is live and working. The shared legacy list (no owner) is migrated only when no owner is live at all, the first 0.6 process into an otherwise-drained cluster; stop 0.5 workers first (see the CHANGELOG). Returns how many jobs were moved.

Source
redis
Source
redis_now_ms

The Redis server clock, in milliseconds, so every process's heartbeat and every reader's TTL cutoff are on one clock: a process is never judged dead because another node's wall clock runs ahead of it.

Source
release_job_claim(queue_name : String, worker_id : String) : Nil

Nothing to release: a claim is the job's place in the processing list, which completion, retry or dead-lettering removes

Source
release_job_claims_batch(queue_name : String, worker_id : String, job_count : Int32) : Nil

Nothing to release (see release_job_claim)

Source
requeue_from_processing(job_json : String, queue_name : String) : Bool

Puts a claimed job back at the tail of its queue (behind the waiting jobs), in one step: it is taken off the processing list and pushed, or (when it is no longer there, e.g. another process reclaimed it) nothing happens. Not counted as processed. Used for a job bounced for missing encryption keys, which should not come straight back.

Source
requeue_many_from_processing(entries : Array(Tuple(String, String))) : Int32

Returns jobs abandoned at shutdown, given as {queue, job} pairs in claim order, to the head of their queues so they run next, in that order; returns how many were put back. Each job is taken off this process's own processing list (or the legacy shared list) and skipped if it is no longer there. The entries are pushed last to first (each push goes in front of the one before), one script per hundred; if a script itself fails, its entries are tried one by one and a failing one is logged and skipped.

Source
reset
Source
schedule(job : Job, delay_in_ms : Int64, delay_set : String = DELAYED_SET) : Nil
Source
schedule_delayed_retry(job : Job, queue_name : String, delay_ms : Int64) : Bool

Schedule delayed retry - simplified version of move_to_retry

Source
schedule_job(job : String, schedule_time : Int64) : Nil
Source
set_concurrency(queue_name : String, count : Int32) : Nil
Source
set_paused(queue_name : String, paused : Bool) : Nil
Source
set_paused_reviewed(queue_name : String, expected : Bool, paused : Bool) : Bool

Compare-and-set for a reviewed operator change. A concurrent operator's state is not overwritten after the preview was taken.

Source
set_size(set_name : String) : Int64
Source
set_sizes_batch(set_names : Array(String)) : Hash(String, Int64)

Optimized batch set sizes to reduce connection overhead

Source
start_owner_heartbeat

Starts this process's liveness heartbeat (idempotent, process-wide). Queue#start calls it before claiming, so every processing list a 0.6 process owns has a live heartbeat while the process runs.

Source
stop_owner_heartbeat

Stops the heartbeat and drops this process's owner entry, so recovery on another process can reclaim this process's lists at once rather than waiting out the TTL. A crash skips this and the entry lapses after the TTL.

Source
verify_job_removed_from_processing?(job_id : String, queue_name : String) : Bool

Verify that a job has been properly removed from processing queue

Source
verify_job_uniqueness(job_jid : String, queue_name : String) : Hash(String, Int32)

Verify that a job exists in only one location (for debugging/testing) Returns a hash with the job's locations

Source

Nested types