philiprehberger-task_queue

Tests Gem Version Last updated

philiprehberger-taskqueue

In-process async job queue with concurrency control

Requirements

  • Ruby >= 3.1

Installation

Add to your Gemfile:

gem "philiprehberger-task_queue"

Or install directly:

gem install philiprehberger-task_queue

Usage

require "philiprehberger/task_queue"

queue = Philiprehberger::TaskQueue.new(concurrency: 4)

10.times do |i|
  queue.push { puts "Processing job #{i}" }
end

puts queue.size      # number of pending tasks
puts queue.running?  # => true

queue.shutdown(timeout: 30)

Using the << alias

queue << -> { puts "Hello from a task!" }

Error handling

Register a callback to handle exceptions raised inside tasks. The callback receives the exception and the original task (callable) that failed. Unhandled errors are silently swallowed when no callback is registered.

queue = Philiprehberger::TaskQueue.new(concurrency: 2)

queue.on_error do |exception, task|
  warn "[TaskQueue] #{exception.class}: #{exception.message}"
  warn exception.backtrace.first(5).join("\n")
end

queue.push { Integer("not_a_number") }
queue.push { File.read("/nonexistent") }

queue.drain(timeout: 5)
puts queue.stats
# => { completed: 0, failed: 2, pending: 0, in_flight: 0, retried: 0 }

Retries and backoff

Failing tasks can be retried automatically. Pass max_retries: (default 0, no retries) and a retry_backoff: policy (:none, :fixed, or :exponential) with a retry_base_delay: in seconds. A task that raises a StandardError is requeued up to max_retries times before being counted as failed. The on_error callback fires on every failed attempt and receives the attempt number as its third argument. The number of retry attempts is tracked in stats[:retried].

queue = Philiprehberger::TaskQueue.new(
  concurrency: 4,
  max_retries: 3,
  retry_backoff: :exponential,  # :none | :fixed | :exponential
  retry_base_delay: 0.5         # seconds; grows 0.5, 1.0, 2.0 for :exponential
)

queue.on_error do |exception, task, attempt|
  warn "[TaskQueue] attempt #{attempt} failed: #{exception.message}"
end

attempts = 0
queue.push do
  attempts += 1
  raise "transient failure" if attempts < 3
  puts "succeeded on attempt #{attempts}"
end

queue.drain(timeout: 30)
puts queue.stats[:retried]  # number of retry attempts made

Completion callback

Register a callback to run after each successful task completion. The callback receives the return value of the task.

queue = Philiprehberger::TaskQueue.new(concurrency: 2)

queue.on_complete do |result|
  puts "Task finished with: #{result}"
end

queue.push { 42 }
queue.push { { status: "ok" } }

queue.drain(timeout: 5)
# Task finished with: 42
# Task finished with: {:status=>"ok"}

Statistics

stats returns a snapshot of completed, failed, pending, and in-flight counts. All counters are thread-safe and updated atomically after each task finishes.

queue = Philiprehberger::TaskQueue.new(concurrency: 4)

20.times { |i| queue.push { sleep(0.01); raise "boom" if i == 5 } }
queue.drain(timeout: 10)

stats = queue.stats
puts "Completed:   #{stats[:completed]}"
puts "Failed:      #{stats[:failed]}"
puts "Pending:     #{stats[:pending]}"
puts "In-flight:   #{stats[:in_flight]}"
puts "Concurrency: #{queue.concurrency}"
# Completed:   19
# Failed:      1
# Pending:     0
# In-flight:   0
# Concurrency: 4

Pause and resume

Temporarily suspend task consumption without shutting down. In-flight tasks will finish, but no new tasks are picked up until the queue is resumed.

queue = Philiprehberger::TaskQueue.new(concurrency: 4)

10.times { |i| queue.push { process(i) } }

queue.pause
puts queue.paused?  # => true

# Tasks already in flight will complete, but pending tasks wait.
queue.resume
puts queue.paused?  # => false

queue.shutdown(timeout: 10)

Clear pending tasks

Discard all pending tasks from the queue. Returns the number of tasks removed.

queue = Philiprehberger::TaskQueue.new(concurrency: 2)

100.times { |i| queue.push { process(i) } }
cleared = queue.clear
puts "Cleared #{cleared} tasks"

queue.shutdown(timeout: 5)

Reset counters

queue = Philiprehberger::TaskQueue.new(concurrency: 4)
10.times { queue.push { do_work } }
queue.drain
queue.stats_reset!
queue.stats[:completed] # => 0

Task priorities

Give tasks a priority: (default 0) to have them dequeued ahead of lower-priority work. Higher priorities run first; tasks that share a priority preserve FIFO (insertion) order. The << alias always enqueues at priority 0.

queue = Philiprehberger::TaskQueue.new(concurrency: 1)
queue.pause

queue.push(priority: 0)  { puts "low" }
queue.push(priority: 10) { puts "high" }
queue.push(priority: 5)  { puts "medium" }

queue.resume
queue.drain(timeout: 5)
# high
# medium
# low

FIFO ordering guarantees

Tasks are stored in an internal array and dequeued in priority-then-FIFO order. With the default priority of 0, ordering is pure FIFO. When concurrency is 1, equal-priority tasks execute strictly in the order they were pushed. With higher concurrency, dequeue order is still priority-then-FIFO but tasks may complete out of order depending on individual execution time.

results = Queue.new  # stdlib thread-safe queue for collecting output
queue = Philiprehberger::TaskQueue.new(concurrency: 1)

5.times { |i| queue.push { results << i } }
queue.drain(timeout: 5)

puts results.size.times.map { results.pop }
# => [0, 1, 2, 3, 4]

Graceful shutdown

shutdown signals all worker threads to stop accepting new tasks, lets in-flight tasks finish, then drains any remaining enqueued tasks before joining threads. The timeout parameter caps total wait time; workers that exceed the deadline are abandoned.

queue = Philiprehberger::TaskQueue.new(concurrency: 4)

100.times { |i| queue.push { sleep(0.05) } }

queue.shutdown(timeout: 10)
puts queue.running?  # => false
# queue.push { ... } would now raise "queue is shut down"

Draining

drain blocks the calling thread until all pending and in-flight tasks finish, but keeps the queue running so new tasks can still be pushed afterwards.

queue = Philiprehberger::TaskQueue.new(concurrency: 4)

10.times { |i| queue.push { process(i) } }
queue.drain(timeout: 10)  # waits for all tasks to finish
puts queue.running?        # => true — still accepting new tasks

queue.push { process(:extra) }
queue.shutdown(timeout: 5)

API

Method Parameters Returns Description
.new(concurrency:, max_retries:, retry_backoff:, retry_base_delay:) concurrency — max worker threads (Integer, default 4); max_retries — retries before a task counts as failed (Integer, default 0); retry_backoff:none/:fixed/:exponential (default :none); retry_base_delay — base delay in seconds (Numeric, default 0.1) Queue Create a new queue; raises ArgumentError on a negative max_retries or unknown retry_backoff
#push(&block) priority — dequeue priority, higher runs first (Integer, default 0); &block — the task to execute self Enqueue a block for async execution; raises ArgumentError if no block given, raises RuntimeError if the queue is shut down
#<<(callable) callable — any object responding to #call self Alias for #push; convenient for lambdas and procs
#size (none) Integer Number of pending (not yet started) tasks
#empty? (none) Boolean Whether there are no pending tasks waiting to be started
#busy? (none) Boolean Whether the queue has any pending tasks or in-flight tasks
#running? (none) Boolean Whether the queue is accepting new tasks
#shutdown(timeout:) timeout — seconds to wait for workers (Numeric, default 30) nil Signal workers to stop, drain remaining tasks, join threads up to timeout seconds
#on_complete(&block) &block — callback receiving (result) self Register a callback invoked after each successful task completion with the task's return value
#on_error(&block) &block — callback receiving (exception, task, attempt) self Register an error callback invoked on every failed attempt when a task raises a StandardError; attempt is the 1-based attempt number
#stats (none) Hash Returns { completed:, failed:, pending:, in_flight:, retried: } with Integer counts (retried is the total number of retry attempts made)
#drain(timeout:) timeout — seconds to wait (Numeric, default 30) nil Block until all pending and in-flight tasks complete without shutting down
#pause (none) self Suspend task consumption; in-flight tasks finish but no new tasks are picked up
#resume (none) self Resume a paused queue, waking workers to continue processing
#paused? (none) Boolean Whether the queue is currently paused
#clear (none) Integer Remove all pending tasks and return the number cleared
#stats_reset! (none) self Atomically zero the completed and failed counters while leaving pending, in-flight, workers, and callbacks untouched
#concurrency (none) Integer Returns the configured maximum number of concurrent worker threads

Development

bundle install
bundle exec rspec
bundle exec rubocop

Support

If you find this project useful:

Star the repo

🐛 Report issues

💡 Suggest features

❤️ Sponsor development

🌐 All Open Source Projects

💻 GitHub Profile

🔗 LinkedIn Profile

License

MIT