philiprehberger-task_queue 0.7.1 → 0.8.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bd60112f6cfee945ef38be345d06ee98eb67f8cf41c58130d878806512f6459
4
- data.tar.gz: 0def7843e02bfa558090f72aa9deee01c9383f5dbc2bbdfa7d8864f3b0e41cef
3
+ metadata.gz: 5017b6c1de6e4dfabd256db6ce882673c7c6feb06b9f77d379120f73ca488332
4
+ data.tar.gz: 62178c519c689ec28af19af6bbb2fa5bf5e54095bcb9cb22da0ceb5ffc413d58
5
5
  SHA512:
6
- metadata.gz: e80d762e3fb12eed38918bc931c8b057052800938746ac83d35b86343e129e1aec1ab41a6f85f971ddf7f96fdcba181fd0e9d69971e347cc2b50ee371bb25340
7
- data.tar.gz: 882bd1d57135f0e52e55dea6bc6a8660e443c4061e0e629dba35ae0ffff9ea43e0ce61848c2c939d4267452def0be6a546584a79219b1102dedf208f9216843d
6
+ metadata.gz: 82445514d31e2af3423bcb480bb14c0dfb20a410bdf745137f2f646c6412e791bec548bbdd46e32bb52d238eb6931b3812fdb7137f007bed061e5c93feafb713
7
+ data.tar.gz: 15e97b694846cf81d93183e257ae9dd8b12f4d0d43f6a71309a43f2ebefcd7dfb0624055d0a564b3052ad4e32a50b2f18c0173ea5c228fb7d9ec303da852528b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-07-15
11
+
12
+ ### Added
13
+ - Automatic retries with backoff — `max_retries:`, `retry_backoff:` (`:none`/`:fixed`/`:exponential`), and `retry_base_delay:` constructor options; failing tasks are requeued up to `max_retries` times before counting as `failed`
14
+ - `retried` counter in `stats` reporting the total number of retry attempts made
15
+ - `on_error` now receives the 1-based attempt number as its third argument
16
+ - Task priorities — `push(priority:)` dequeues higher-priority tasks first while preserving FIFO order within the same priority (default priority `0` keeps pure FIFO behavior)
17
+
18
+ ### Fixed
19
+ - Callbacks registered after the first `push` (via `on_error`/`on_complete`) now fire; handlers are read live at execution time instead of being snapshotted when workers start
20
+ - User callbacks are isolated from stats accounting and the worker loop — a raising completion or error callback can no longer corrupt the `in_flight`/`completed`/`failed` counters or kill a worker thread
21
+
10
22
  ## [0.7.1] - 2026-06-14
11
23
 
12
24
  ### Changed
@@ -128,3 +140,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
128
140
  - Thread-safe task enqueuing with `push` / `<<`
129
141
  - Graceful shutdown with timeout support
130
142
  - Auto-starting worker threads on first push
143
+
144
+ [Unreleased]: https://github.com/philiprehberger/rb-task-queue/compare/v0.8.0...HEAD
145
+ [0.8.0]: https://github.com/philiprehberger/rb-task-queue/compare/v0.7.1...v0.8.0
data/README.md CHANGED
@@ -66,7 +66,34 @@ queue.push { File.read("/nonexistent") }
66
66
 
67
67
  queue.drain(timeout: 5)
68
68
  puts queue.stats
69
- # => { completed: 0, failed: 2, pending: 0, in_flight: 0 }
69
+ # => { completed: 0, failed: 2, pending: 0, in_flight: 0, retried: 0 }
70
+ ```
71
+
72
+ ### Retries and backoff
73
+
74
+ 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]`.
75
+
76
+ ```ruby
77
+ queue = Philiprehberger::TaskQueue.new(
78
+ concurrency: 4,
79
+ max_retries: 3,
80
+ retry_backoff: :exponential, # :none | :fixed | :exponential
81
+ retry_base_delay: 0.5 # seconds; grows 0.5, 1.0, 2.0 for :exponential
82
+ )
83
+
84
+ queue.on_error do |exception, task, attempt|
85
+ warn "[TaskQueue] attempt #{attempt} failed: #{exception.message}"
86
+ end
87
+
88
+ attempts = 0
89
+ queue.push do
90
+ attempts += 1
91
+ raise "transient failure" if attempts < 3
92
+ puts "succeeded on attempt #{attempts}"
93
+ end
94
+
95
+ queue.drain(timeout: 30)
96
+ puts queue.stats[:retried] # number of retry attempts made
70
97
  ```
71
98
 
72
99
  ### Completion callback
@@ -154,9 +181,28 @@ queue.stats_reset!
154
181
  queue.stats[:completed] # => 0
155
182
  ```
156
183
 
184
+ ### Task priorities
185
+
186
+ 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`.
187
+
188
+ ```ruby
189
+ queue = Philiprehberger::TaskQueue.new(concurrency: 1)
190
+ queue.pause
191
+
192
+ queue.push(priority: 0) { puts "low" }
193
+ queue.push(priority: 10) { puts "high" }
194
+ queue.push(priority: 5) { puts "medium" }
195
+
196
+ queue.resume
197
+ queue.drain(timeout: 5)
198
+ # high
199
+ # medium
200
+ # low
201
+ ```
202
+
157
203
  ### FIFO ordering guarantees
158
204
 
159
- Tasks are stored in an internal array and dequeued in FIFO order. When `concurrency` is `1`, tasks execute strictly in the order they were pushed. With higher concurrency, dequeue order is still FIFO but tasks may complete out of order depending on individual execution time.
205
+ 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.
160
206
 
161
207
  ```ruby
162
208
  results = Queue.new # stdlib thread-safe queue for collecting output
@@ -202,8 +248,8 @@ queue.shutdown(timeout: 5)
202
248
 
203
249
  | Method | Parameters | Returns | Description |
204
250
  |---|---|---|---|
205
- | `.new(concurrency:)` | `concurrency` — max worker threads (Integer, default `4`) | `Queue` | Create a new queue with the given concurrency limit |
206
- | `#push(&block)` | `&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 |
251
+ | `.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` |
252
+ | `#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 |
207
253
  | `#<<(callable)` | `callable` — any object responding to `#call` | `self` | Alias for `#push`; convenient for lambdas and procs |
208
254
  | `#size` | _(none)_ | `Integer` | Number of pending (not yet started) tasks |
209
255
  | `#empty?` | _(none)_ | `Boolean` | Whether there are no pending tasks waiting to be started |
@@ -211,8 +257,8 @@ queue.shutdown(timeout: 5)
211
257
  | `#running?` | _(none)_ | `Boolean` | Whether the queue is accepting new tasks |
212
258
  | `#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 |
213
259
  | `#on_complete(&block)` | `&block` — callback receiving `(result)` | `self` | Register a callback invoked after each successful task completion with the task's return value |
214
- | `#on_error(&block)` | `&block` — callback receiving `(exception, task)` | `self` | Register an error callback invoked when a task raises a `StandardError` |
215
- | `#stats` | _(none)_ | `Hash` | Returns `{ completed:, failed:, pending:, in_flight: }` with Integer counts |
260
+ | `#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 |
261
+ | `#stats` | _(none)_ | `Hash` | Returns `{ completed:, failed:, pending:, in_flight:, retried: }` with Integer counts (`retried` is the total number of retry attempts made) |
216
262
  | `#drain(timeout:)` | `timeout` — seconds to wait (Numeric, default `30`) | `nil` | Block until all pending and in-flight tasks complete without shutting down |
217
263
  | `#pause` | _(none)_ | `self` | Suspend task consumption; in-flight tasks finish but no new tasks are picked up |
218
264
  | `#resume` | _(none)_ | `self` | Resume a paused queue, waking workers to continue processing |
@@ -4,17 +4,36 @@ require_relative 'worker'
4
4
 
5
5
  module Philiprehberger
6
6
  module TaskQueue
7
+ # Internal representation of an enqueued task, carrying its priority and the
8
+ # number of attempts made so far (for retry accounting).
9
+ Entry = Struct.new(:callable, :priority, :attempts)
10
+
7
11
  # In-process async job queue with concurrency control.
8
12
  #
9
13
  # Tasks are enqueued as blocks or callable objects and executed by a pool of
10
14
  # worker threads. The queue is fully thread-safe.
11
15
  class Queue
16
+ # Supported retry backoff policies.
17
+ BACKOFF_POLICIES = %i[none fixed exponential].freeze
18
+
12
19
  # @return [Integer] the maximum number of concurrent worker threads
13
20
  attr_reader :concurrency
14
21
 
15
22
  # @param concurrency [Integer] maximum number of concurrent worker threads
16
- def initialize(concurrency: 4)
23
+ # @param max_retries [Integer] how many times a failing task is requeued
24
+ # before it is counted as failed (default +0+, i.e. no retries)
25
+ # @param retry_backoff [Symbol] delay policy between retries — one of
26
+ # +:none+, +:fixed+, or +:exponential+
27
+ # @param retry_base_delay [Numeric] base delay in seconds used by the
28
+ # +:fixed+ and +:exponential+ backoff policies
29
+ # @raise [ArgumentError] if +max_retries+ is negative or +retry_backoff+
30
+ # is not a recognized policy
31
+ def initialize(concurrency: 4, max_retries: 0, retry_backoff: :none, retry_base_delay: 0.1)
17
32
  @concurrency = concurrency
33
+ @max_retries = max_retries
34
+ @retry_backoff = retry_backoff
35
+ @retry_base_delay = retry_base_delay
36
+ validate_retry_options!
18
37
  @tasks = []
19
38
  @mutex = Mutex.new
20
39
  @condition = ConditionVariable.new
@@ -26,14 +45,19 @@ module Philiprehberger
26
45
  @pause_condition = ConditionVariable.new
27
46
  @error_handler = nil
28
47
  @complete_handler = nil
29
- @stats = { completed: 0, failed: 0, in_flight: 0 }
48
+ @stats = { completed: 0, failed: 0, in_flight: 0, retried: 0 }
30
49
  end
31
50
 
32
51
  # Register a callback invoked when a task raises an exception.
33
52
  #
34
- # The callback receives the exception and the task that raised it.
53
+ # The callback receives the exception, the task that raised it, and the
54
+ # attempt number (1 for the first try, incrementing on each retry). It
55
+ # fires on every failed attempt, including the ones that are retried.
35
56
  #
36
- # @yield [exception, task] called on task failure
57
+ # The callback may be registered at any time — even after tasks have been
58
+ # pushed — and is read live when the task runs.
59
+ #
60
+ # @yield [exception, task, attempt] called on each task failure
37
61
  # @return [self]
38
62
  def on_error(&block)
39
63
  @mutex.synchronize { @error_handler = block }
@@ -53,11 +77,12 @@ module Philiprehberger
53
77
 
54
78
  # Return statistics about processed tasks.
55
79
  #
56
- # @return [Hash{Symbol => Integer}] counts for :completed, :failed, :pending, :in_flight
80
+ # @return [Hash{Symbol => Integer}] counts for :completed, :failed,
81
+ # :pending, :in_flight, and :retried (total retry attempts made)
57
82
  def stats
58
83
  @mutex.synchronize do
59
84
  { completed: @stats[:completed], failed: @stats[:failed], pending: @tasks.size,
60
- in_flight: @stats[:in_flight] }
85
+ in_flight: @stats[:in_flight], retried: @stats[:retried] }
61
86
  end
62
87
  end
63
88
 
@@ -137,10 +162,15 @@ module Philiprehberger
137
162
 
138
163
  # Enqueue a task to be processed asynchronously.
139
164
  #
165
+ # Higher-priority tasks are dequeued before lower-priority ones; tasks
166
+ # that share a priority run in FIFO (insertion) order. The +<<+ alias
167
+ # always enqueues at the default priority of +0+.
168
+ #
140
169
  # @param callable [#call, nil] a callable object (used by +<<+)
170
+ # @param priority [Integer] dequeue priority; higher runs first (default +0+)
141
171
  # @yield the block to execute (takes precedence over +callable+)
142
172
  # @return [self]
143
- def push(callable = nil, &block)
173
+ def push(callable = nil, priority: 0, &block)
144
174
  task = block || callable
145
175
  raise ArgumentError, 'a block is required' unless task
146
176
 
@@ -148,7 +178,7 @@ module Philiprehberger
148
178
  raise 'queue is shut down' unless @running
149
179
 
150
180
  start_workers unless @started
151
- @tasks << task
181
+ @tasks << Entry.new(task, priority, 0)
152
182
  @condition.signal
153
183
  end
154
184
 
@@ -229,13 +259,24 @@ module Philiprehberger
229
259
  @concurrency.times do
230
260
  @workers << Worker.new(
231
261
  @tasks, @mutex, @condition,
232
- context: { stats: @stats, error_handler: @error_handler,
233
- complete_handler: @complete_handler, drain_condition: @drain_condition,
234
- paused: -> { @paused }, pause_condition: @pause_condition }
262
+ context: { stats: @stats,
263
+ error_handler: -> { @error_handler },
264
+ complete_handler: -> { @complete_handler },
265
+ drain_condition: @drain_condition,
266
+ paused: -> { @paused }, pause_condition: @pause_condition,
267
+ max_retries: @max_retries, retry_backoff: @retry_backoff,
268
+ retry_base_delay: @retry_base_delay }
235
269
  )
236
270
  end
237
271
  @started = true
238
272
  end
273
+
274
+ def validate_retry_options!
275
+ raise ArgumentError, 'max_retries must be >= 0' if @max_retries.negative?
276
+ return if BACKOFF_POLICIES.include?(@retry_backoff)
277
+
278
+ raise ArgumentError, "unknown retry_backoff: #{@retry_backoff.inspect} (expected one of #{BACKOFF_POLICIES})"
279
+ end
239
280
  end
240
281
  end
241
282
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module TaskQueue
5
- VERSION = '0.7.1'
5
+ VERSION = '0.8.0'
6
6
  end
7
7
  end
@@ -16,6 +16,9 @@ module Philiprehberger
16
16
  @drain_condition = context[:drain_condition]
17
17
  @paused = context[:paused]
18
18
  @pause_condition = context[:pause_condition]
19
+ @max_retries = context[:max_retries]
20
+ @retry_backoff = context[:retry_backoff]
21
+ @retry_base_delay = context[:retry_base_delay]
19
22
  @running = true
20
23
  @thread = Thread.new { run }
21
24
  end
@@ -39,32 +42,49 @@ module Philiprehberger
39
42
 
40
43
  def run
41
44
  loop do
42
- task = next_task
43
- break unless task
45
+ entry = next_task
46
+ break unless entry
44
47
 
45
- execute(task)
48
+ execute(entry)
46
49
  @mutex.synchronize { @drain_condition.broadcast }
47
50
  end
48
51
  end
49
52
 
50
53
  def next_task
51
54
  @mutex.synchronize do
52
- @condition.wait(@mutex) while @queue.empty? && @running
53
- return nil unless @running || !@queue.empty?
55
+ loop do
56
+ @condition.wait(@mutex) while @queue.empty? && @running
57
+ return nil unless @running || !@queue.empty?
54
58
 
55
- @pause_condition.wait(@mutex) while @paused&.call && @running
56
- return nil unless @running || !@queue.empty?
59
+ @pause_condition.wait(@mutex) while @paused&.call && @running
60
+ return nil unless @running || !@queue.empty?
57
61
 
58
- @stats[:in_flight] += 1
59
- @queue.shift
62
+ # Another worker may have taken the last task while we waited.
63
+ next if @queue.empty?
64
+
65
+ @stats[:in_flight] += 1
66
+ return dequeue
67
+ end
68
+ end
69
+ end
70
+
71
+ # Remove and return the highest-priority pending entry. Ties are broken by
72
+ # insertion order (FIFO), so equal-priority tasks keep their arrival order.
73
+ def dequeue
74
+ best = 0
75
+ index = 1
76
+ while index < @queue.size
77
+ best = index if @queue[index].priority > @queue[best].priority
78
+ index += 1
60
79
  end
80
+ @queue.delete_at(best)
61
81
  end
62
82
 
63
- def execute(task)
64
- result = task.call
83
+ def execute(entry)
84
+ result = entry.callable.call
65
85
  record_completion(result)
66
86
  rescue StandardError => e
67
- record_failure(e, task)
87
+ handle_failure(entry, e)
68
88
  end
69
89
 
70
90
  def record_completion(result)
@@ -72,15 +92,62 @@ module Philiprehberger
72
92
  @stats[:completed] += 1
73
93
  @stats[:in_flight] -= 1
74
94
  end
75
- @complete_handler&.call(result)
95
+ invoke_complete(result)
96
+ end
97
+
98
+ def handle_failure(entry, error)
99
+ entry.attempts += 1
100
+ attempt = entry.attempts
101
+ invoke_error(error, entry.callable, attempt)
102
+
103
+ if attempt <= @max_retries
104
+ requeue(entry, attempt)
105
+ else
106
+ record_failure
107
+ end
108
+ end
109
+
110
+ def requeue(entry, attempt)
111
+ delay = backoff_delay(attempt)
112
+ sleep(delay) if delay.positive?
113
+ @mutex.synchronize do
114
+ @stats[:in_flight] -= 1
115
+ @stats[:retried] += 1
116
+ @queue << entry
117
+ @condition.signal
118
+ end
76
119
  end
77
120
 
78
- def record_failure(error, task)
121
+ def record_failure
79
122
  @mutex.synchronize do
80
123
  @stats[:failed] += 1
81
124
  @stats[:in_flight] -= 1
82
125
  end
83
- @error_handler&.call(error, task)
126
+ end
127
+
128
+ def backoff_delay(attempt)
129
+ case @retry_backoff
130
+ when :fixed then @retry_base_delay
131
+ when :exponential then @retry_base_delay * (2**(attempt - 1))
132
+ else 0
133
+ end
134
+ end
135
+
136
+ # User callbacks run outside the stats-accounting path and are each
137
+ # isolated in their own rescue, so a raising callback can neither corrupt
138
+ # the counters nor unwind the worker thread.
139
+ def invoke_complete(result)
140
+ handler = @complete_handler.call
141
+ handler&.call(result)
142
+ rescue StandardError
143
+ nil
144
+ end
145
+
146
+ def invoke_error(error, task, attempt)
147
+ handler = @error_handler.call
148
+ handler&.call(error, task, attempt)
149
+ rescue StandardError
150
+ nil
84
151
  end
85
152
  end
86
153
  end
@@ -5,6 +5,9 @@ require_relative 'task_queue/queue'
5
5
 
6
6
  module Philiprehberger
7
7
  module TaskQueue
8
+ # Base error class for all TaskQueue-specific errors.
9
+ class Error < StandardError; end
10
+
8
11
  # Convenience constructor.
9
12
  #
10
13
  # @param options [Hash] forwarded to {Queue#initialize}
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-task_queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-15 00:00:00.000000000 Z
11
+ date: 2026-07-16 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A lightweight, zero-dependency, thread-safe in-process async job queue
14
14
  with configurable concurrency for Ruby applications.