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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +73 -7
- data/lib/philiprehberger/task_queue/queue.rb +88 -11
- data/lib/philiprehberger/task_queue/version.rb +1 -1
- data/lib/philiprehberger/task_queue/worker.rb +82 -15
- data/lib/philiprehberger/task_queue.rb +7 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b052683a824db4cfaab12c8216cb66a99d40580d085ae630a3dfda88879b82d
|
|
4
|
+
data.tar.gz: de60b9e402664d5688461aa1e5659a3a686646de142ce2123728c61f5da7ff35
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
|
|
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,
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
@@ -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
|
-
|
|
43
|
-
break unless
|
|
45
|
+
entry = next_task
|
|
46
|
+
break unless entry
|
|
44
47
|
|
|
45
|
-
execute(
|
|
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
|
-
|
|
53
|
-
|
|
55
|
+
loop do
|
|
56
|
+
@condition.wait(@mutex) while @queue.empty? && @running
|
|
57
|
+
return nil unless @running || !@queue.empty?
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
@pause_condition.wait(@mutex) while @paused&.call && @running
|
|
60
|
+
return nil unless @running || !@queue.empty?
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
64
|
-
result =
|
|
83
|
+
def execute(entry)
|
|
84
|
+
result = entry.callable.call
|
|
65
85
|
record_completion(result)
|
|
66
86
|
rescue StandardError => e
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
121
|
+
def record_failure
|
|
79
122
|
@mutex.synchronize do
|
|
80
123
|
@stats[:failed] += 1
|
|
81
124
|
@stats[:in_flight] -= 1
|
|
82
125
|
end
|
|
83
|
-
|
|
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.
|
|
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-
|
|
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.
|