philiprehberger-task_queue 0.7.1 → 0.9.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: 3b052683a824db4cfaab12c8216cb66a99d40580d085ae630a3dfda88879b82d
4
+ data.tar.gz: de60b9e402664d5688461aa1e5659a3a686646de142ce2123728c61f5da7ff35
5
5
  SHA512:
6
- metadata.gz: e80d762e3fb12eed38918bc931c8b057052800938746ac83d35b86343e129e1aec1ab41a6f85f971ddf7f96fdcba181fd0e9d69971e347cc2b50ee371bb25340
7
- data.tar.gz: 882bd1d57135f0e52e55dea6bc6a8660e443c4061e0e629dba35ae0ffff9ea43e0ce61848c2c939d4267452def0be6a546584a79219b1102dedf208f9216843d
6
+ metadata.gz: 76e39073b3e7a723836810427fc10a7e7777831e4f5473ecd5cb4cd0a9424eb9b91c4f303d5d9bc8e33f293e8d6342d77d6e8f9f45e75a9bfeedfa6c551779e1
7
+ data.tar.gz: 287f3097f118a996bd0dc0b10edb86c9c904fdf62cfd8d4113b72e1808582309cae98409a520dd0d3edd39e7320f4af5b845848d318a2f0ea11d5be2d78ba2cc
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-10-30
11
+
12
+ ### Added
13
+ - Bounded queue — `max_size:` constructor option caps the number of pending tasks (`nil`, the default, keeps the queue unbounded); the capacity check and enqueue happen atomically under the queue mutex
14
+ - `Philiprehberger::TaskQueue::QueueFullError` (subclass of `Error`) raised by `push`/`<<` when a bounded queue is full
15
+ - `Queue#full?` — whether the pending task count has reached `max_size` (always `false` when unbounded)
16
+ - `Queue#max_size` — reader for the configured limit
17
+
18
+ ### Changed
19
+ - `.gitignore` aligned with the gem guide template
20
+ - CI workflow aligned with the gem guide template, publishing via a RubyGems credentials file
21
+ - Feature request issue template now includes a proposed-API code placeholder
22
+
23
+ ## [0.8.0] - 2026-07-15
24
+
25
+ ### Added
26
+ - 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`
27
+ - `retried` counter in `stats` reporting the total number of retry attempts made
28
+ - `on_error` now receives the 1-based attempt number as its third argument
29
+ - Task priorities — `push(priority:)` dequeues higher-priority tasks first while preserving FIFO order within the same priority (default priority `0` keeps pure FIFO behavior)
30
+
31
+ ### Fixed
32
+ - 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
33
+ - 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
34
+
10
35
  ## [0.7.1] - 2026-06-14
11
36
 
12
37
  ### Changed
@@ -128,3 +153,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
128
153
  - Thread-safe task enqueuing with `push` / `<<`
129
154
  - Graceful shutdown with timeout support
130
155
  - Auto-starting worker threads on first push
156
+
157
+ [Unreleased]: https://github.com/philiprehberger/rb-task-queue/compare/v0.8.0...HEAD
158
+ [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,45 @@ 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
+
203
+ ### Bounded queue
204
+
205
+ Pass `max_size:` to cap the number of pending (not yet started) tasks. Once the limit is reached, `push` and `<<` raise `Philiprehberger::TaskQueue::QueueFullError` (a subclass of `Philiprehberger::TaskQueue::Error`) instead of growing without bound. The capacity check and the enqueue happen atomically, so concurrent producers can never overfill the queue. Tasks requeued for a retry are not counted against the limit. The default, `nil`, leaves the queue unbounded.
206
+
207
+ ```ruby
208
+ queue = Philiprehberger::TaskQueue.new(concurrency: 2, max_size: 100)
209
+ puts queue.max_size # => 100
210
+
211
+ begin
212
+ queue.push { process(job) }
213
+ rescue Philiprehberger::TaskQueue::QueueFullError
214
+ # apply backpressure: drop, retry later, or slow the producer
215
+ end
216
+
217
+ puts queue.full? # => true once 100 tasks are pending
218
+ ```
219
+
157
220
  ### FIFO ordering guarantees
158
221
 
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.
222
+ 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
223
 
161
224
  ```ruby
162
225
  results = Queue.new # stdlib thread-safe queue for collecting output
@@ -202,17 +265,17 @@ queue.shutdown(timeout: 5)
202
265
 
203
266
  | Method | Parameters | Returns | Description |
204
267
  |---|---|---|---|
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 |
207
- | `#<<(callable)` | `callable` — any object responding to `#call` | `self` | Alias for `#push`; convenient for lambdas and procs |
268
+ | `.new(concurrency:, max_retries:, retry_backoff:, retry_base_delay:, max_size:)` | `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`); `max_size` — max pending tasks (Integer or `nil`, default `nil` for unbounded) | `Queue` | Create a new queue; raises `ArgumentError` on a negative `max_retries`, unknown `retry_backoff`, or a `max_size` that is not a positive Integer |
269
+ | `#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, raises `QueueFullError` if the queue holds `max_size` pending tasks |
270
+ | `#<<(callable)` | `callable` — any object responding to `#call` | `self` | Alias for `#push`; convenient for lambdas and procs; raises `QueueFullError` when full |
208
271
  | `#size` | _(none)_ | `Integer` | Number of pending (not yet started) tasks |
209
272
  | `#empty?` | _(none)_ | `Boolean` | Whether there are no pending tasks waiting to be started |
210
273
  | `#busy?` | _(none)_ | `Boolean` | Whether the queue has any pending tasks or in-flight tasks |
211
274
  | `#running?` | _(none)_ | `Boolean` | Whether the queue is accepting new tasks |
212
275
  | `#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
276
  | `#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 |
277
+ | `#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 |
278
+ | `#stats` | _(none)_ | `Hash` | Returns `{ completed:, failed:, pending:, in_flight:, retried: }` with Integer counts (`retried` is the total number of retry attempts made) |
216
279
  | `#drain(timeout:)` | `timeout` — seconds to wait (Numeric, default `30`) | `nil` | Block until all pending and in-flight tasks complete without shutting down |
217
280
  | `#pause` | _(none)_ | `self` | Suspend task consumption; in-flight tasks finish but no new tasks are picked up |
218
281
  | `#resume` | _(none)_ | `self` | Resume a paused queue, waking workers to continue processing |
@@ -220,6 +283,9 @@ queue.shutdown(timeout: 5)
220
283
  | `#clear` | _(none)_ | `Integer` | Remove all pending tasks and return the number cleared |
221
284
  | `#stats_reset!` | _(none)_ | `self` | Atomically zero the `completed` and `failed` counters while leaving pending, in-flight, workers, and callbacks untouched |
222
285
  | `#concurrency` | _(none)_ | `Integer` | Returns the configured maximum number of concurrent worker threads |
286
+ | `#max_size` | _(none)_ | `Integer`, `nil` | Returns the configured pending-task limit, or `nil` when unbounded |
287
+ | `#full?` | _(none)_ | `Boolean` | Whether the pending task count has reached `max_size`; always `false` when unbounded |
288
+ | `QueueFullError` | _(class)_ | — | Raised by `#push`/`#<<` on a full bounded queue; subclass of `Philiprehberger::TaskQueue::Error` |
223
289
 
224
290
  ## Development
225
291
 
@@ -4,17 +4,44 @@ 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
 
22
+ # @return [Integer, nil] the maximum number of pending tasks, or +nil+
23
+ # when the queue is unbounded
24
+ attr_reader :max_size
25
+
15
26
  # @param concurrency [Integer] maximum number of concurrent worker threads
16
- def initialize(concurrency: 4)
27
+ # @param max_retries [Integer] how many times a failing task is requeued
28
+ # before it is counted as failed (default +0+, i.e. no retries)
29
+ # @param retry_backoff [Symbol] delay policy between retries — one of
30
+ # +:none+, +:fixed+, or +:exponential+
31
+ # @param retry_base_delay [Numeric] base delay in seconds used by the
32
+ # +:fixed+ and +:exponential+ backoff policies
33
+ # @param max_size [Integer, nil] maximum number of pending (not yet
34
+ # started) tasks; +nil+ (the default) leaves the queue unbounded
35
+ # @raise [ArgumentError] if +max_retries+ is negative, +retry_backoff+
36
+ # is not a recognized policy, or +max_size+ is not a positive Integer
37
+ def initialize(concurrency: 4, max_retries: 0, retry_backoff: :none, retry_base_delay: 0.1, max_size: nil)
17
38
  @concurrency = concurrency
39
+ @max_size = max_size
40
+ validate_max_size!
41
+ @max_retries = max_retries
42
+ @retry_backoff = retry_backoff
43
+ @retry_base_delay = retry_base_delay
44
+ validate_retry_options!
18
45
  @tasks = []
19
46
  @mutex = Mutex.new
20
47
  @condition = ConditionVariable.new
@@ -26,14 +53,19 @@ module Philiprehberger
26
53
  @pause_condition = ConditionVariable.new
27
54
  @error_handler = nil
28
55
  @complete_handler = nil
29
- @stats = { completed: 0, failed: 0, in_flight: 0 }
56
+ @stats = { completed: 0, failed: 0, in_flight: 0, retried: 0 }
30
57
  end
31
58
 
32
59
  # Register a callback invoked when a task raises an exception.
33
60
  #
34
- # The callback receives the exception and the task that raised it.
61
+ # The callback receives the exception, the task that raised it, and the
62
+ # attempt number (1 for the first try, incrementing on each retry). It
63
+ # fires on every failed attempt, including the ones that are retried.
35
64
  #
36
- # @yield [exception, task] called on task failure
65
+ # The callback may be registered at any time — even after tasks have been
66
+ # pushed — and is read live when the task runs.
67
+ #
68
+ # @yield [exception, task, attempt] called on each task failure
37
69
  # @return [self]
38
70
  def on_error(&block)
39
71
  @mutex.synchronize { @error_handler = block }
@@ -53,11 +85,12 @@ module Philiprehberger
53
85
 
54
86
  # Return statistics about processed tasks.
55
87
  #
56
- # @return [Hash{Symbol => Integer}] counts for :completed, :failed, :pending, :in_flight
88
+ # @return [Hash{Symbol => Integer}] counts for :completed, :failed,
89
+ # :pending, :in_flight, and :retried (total retry attempts made)
57
90
  def stats
58
91
  @mutex.synchronize do
59
92
  { completed: @stats[:completed], failed: @stats[:failed], pending: @tasks.size,
60
- in_flight: @stats[:in_flight] }
93
+ in_flight: @stats[:in_flight], retried: @stats[:retried] }
61
94
  end
62
95
  end
63
96
 
@@ -137,18 +170,30 @@ module Philiprehberger
137
170
 
138
171
  # Enqueue a task to be processed asynchronously.
139
172
  #
173
+ # Higher-priority tasks are dequeued before lower-priority ones; tasks
174
+ # that share a priority run in FIFO (insertion) order. The +<<+ alias
175
+ # always enqueues at the default priority of +0+.
176
+ #
177
+ # When the queue was created with +max_size+, the capacity check and the
178
+ # enqueue happen atomically, so concurrent pushers can never overfill it.
179
+ # Tasks requeued for a retry are not subject to the limit.
180
+ #
140
181
  # @param callable [#call, nil] a callable object (used by +<<+)
182
+ # @param priority [Integer] dequeue priority; higher runs first (default +0+)
141
183
  # @yield the block to execute (takes precedence over +callable+)
142
184
  # @return [self]
143
- def push(callable = nil, &block)
185
+ # @raise [ArgumentError] if neither a block nor a callable is given
186
+ # @raise [QueueFullError] if the queue already holds +max_size+ pending tasks
187
+ def push(callable = nil, priority: 0, &block)
144
188
  task = block || callable
145
189
  raise ArgumentError, 'a block is required' unless task
146
190
 
147
191
  @mutex.synchronize do
148
192
  raise 'queue is shut down' unless @running
193
+ raise QueueFullError, "queue is full (max_size: #{@max_size})" if at_capacity?
149
194
 
150
195
  start_workers unless @started
151
- @tasks << task
196
+ @tasks << Entry.new(task, priority, 0)
152
197
  @condition.signal
153
198
  end
154
199
 
@@ -173,6 +218,15 @@ module Philiprehberger
173
218
  @mutex.synchronize { @tasks.empty? }
174
219
  end
175
220
 
221
+ # Whether the pending task count has reached +max_size+.
222
+ #
223
+ # Always +false+ for an unbounded queue.
224
+ #
225
+ # @return [Boolean]
226
+ def full?
227
+ @mutex.synchronize { at_capacity? }
228
+ end
229
+
176
230
  # Whether the queue has any pending tasks or any in-flight tasks.
177
231
  #
178
232
  # Convenient inverse of "idle" for callers polling without going
@@ -205,6 +259,18 @@ module Philiprehberger
205
259
 
206
260
  private
207
261
 
262
+ # Must be called while holding +@mutex+.
263
+ def at_capacity?
264
+ !@max_size.nil? && @tasks.size >= @max_size
265
+ end
266
+
267
+ def validate_max_size!
268
+ return if @max_size.nil?
269
+ return if @max_size.is_a?(Integer) && @max_size.positive?
270
+
271
+ raise ArgumentError, "max_size must be a positive Integer or nil, got #{@max_size.inspect}"
272
+ end
273
+
208
274
  def signal_shutdown
209
275
  @mutex.synchronize do
210
276
  return unless @running
@@ -229,13 +295,24 @@ module Philiprehberger
229
295
  @concurrency.times do
230
296
  @workers << Worker.new(
231
297
  @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 }
298
+ context: { stats: @stats,
299
+ error_handler: -> { @error_handler },
300
+ complete_handler: -> { @complete_handler },
301
+ drain_condition: @drain_condition,
302
+ paused: -> { @paused }, pause_condition: @pause_condition,
303
+ max_retries: @max_retries, retry_backoff: @retry_backoff,
304
+ retry_base_delay: @retry_base_delay }
235
305
  )
236
306
  end
237
307
  @started = true
238
308
  end
309
+
310
+ def validate_retry_options!
311
+ raise ArgumentError, 'max_retries must be >= 0' if @max_retries.negative?
312
+ return if BACKOFF_POLICIES.include?(@retry_backoff)
313
+
314
+ raise ArgumentError, "unknown retry_backoff: #{@retry_backoff.inspect} (expected one of #{BACKOFF_POLICIES})"
315
+ end
239
316
  end
240
317
  end
241
318
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module TaskQueue
5
- VERSION = '0.7.1'
5
+ VERSION = '0.9.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,13 @@ 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
+
11
+ # Raised by {Queue#push} when a bounded queue already holds +max_size+
12
+ # pending tasks.
13
+ class QueueFullError < Error; end
14
+
8
15
  # Convenience constructor.
9
16
  #
10
17
  # @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.9.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-10-30 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.