sidekiq-buffered 0.1.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 +7 -0
- data/.rspec +2 -0
- data/.rubocop.yml +64 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +32 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +432 -0
- data/Rakefile +8 -0
- data/lib/active_job/queue_adapters/sidekiq_buffered_adapter.rb +61 -0
- data/lib/sidekiq/buffered/active_job_execution.rb +49 -0
- data/lib/sidekiq/buffered/active_job_retry.rb +12 -0
- data/lib/sidekiq/buffered/batch.rb +70 -0
- data/lib/sidekiq/buffered/batch_context.rb +37 -0
- data/lib/sidekiq/buffered/batch_record.rb +79 -0
- data/lib/sidekiq/buffered/client.rb +109 -0
- data/lib/sidekiq/buffered/client_middleware.rb +38 -0
- data/lib/sidekiq/buffered/collector.rb +63 -0
- data/lib/sidekiq/buffered/config.rb +26 -0
- data/lib/sidekiq/buffered/dispatch.rb +95 -0
- data/lib/sidekiq/buffered/events.rb +29 -0
- data/lib/sidekiq/buffered/execution.rb +143 -0
- data/lib/sidekiq/buffered/heartbeat.rb +96 -0
- data/lib/sidekiq/buffered/inline.rb +17 -0
- data/lib/sidekiq/buffered/input.rb +122 -0
- data/lib/sidekiq/buffered/periodic_thread.rb +45 -0
- data/lib/sidekiq/buffered/poller.rb +81 -0
- data/lib/sidekiq/buffered/processor.rb +42 -0
- data/lib/sidekiq/buffered/registry.rb +38 -0
- data/lib/sidekiq/buffered/retry_callbacks.rb +32 -0
- data/lib/sidekiq/buffered/scheduled_input.rb +21 -0
- data/lib/sidekiq/buffered/scope.rb +17 -0
- data/lib/sidekiq/buffered/script.rb +48 -0
- data/lib/sidekiq/buffered/scripts/ack.lua +5 -0
- data/lib/sidekiq/buffered/scripts/append.lua +64 -0
- data/lib/sidekiq/buffered/scripts/claim.lua +29 -0
- data/lib/sidekiq/buffered/scripts/cleanup_failed.lua +7 -0
- data/lib/sidekiq/buffered/scripts/discard.lua +7 -0
- data/lib/sidekiq/buffered/scripts/exhaust.lua +10 -0
- data/lib/sidekiq/buffered/scripts/extend.lua +7 -0
- data/lib/sidekiq/buffered/scripts/flush_due.lua +25 -0
- data/lib/sidekiq/buffered/scripts/helpers/delete_batch.lua +21 -0
- data/lib/sidekiq/buffered/scripts/helpers/materialize_batch.lua +21 -0
- data/lib/sidekiq/buffered/scripts/helpers/redis_time.lua +6 -0
- data/lib/sidekiq/buffered/scripts/record_dispatch.lua +5 -0
- data/lib/sidekiq/buffered/scripts/recover.lua +21 -0
- data/lib/sidekiq/buffered/scripts/release.lua +8 -0
- data/lib/sidekiq/buffered/scripts/replay.lua +11 -0
- data/lib/sidekiq/buffered/scripts/retrying.lua +13 -0
- data/lib/sidekiq/buffered/scripts/tick.lua +10 -0
- data/lib/sidekiq/buffered/scripts.rb +23 -0
- data/lib/sidekiq/buffered/server_middleware.rb +51 -0
- data/lib/sidekiq/buffered/setter.rb +41 -0
- data/lib/sidekiq/buffered/statistics.rb +130 -0
- data/lib/sidekiq/buffered/store.rb +363 -0
- data/lib/sidekiq/buffered/testing.rb +65 -0
- data/lib/sidekiq/buffered/thread_local.rb +14 -0
- data/lib/sidekiq/buffered/version.rb +5 -0
- data/lib/sidekiq/buffered.rb +172 -0
- data/lib/sidekiq-buffered.rb +1 -0
- data/sidekiq-buffered.gemspec +37 -0
- metadata +216 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cf1f0ec18cf0aa67f5a920af04b9ad90395b0e92498a294e3662fa4f705a34c4
|
|
4
|
+
data.tar.gz: 149f62313b6613c8cb924b1d1c4b82a1383d07ce1aa3ad974648ef385322a355
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c7ac58054551cc0ddc95adeb9b5c0fedf5858f4d2c372cafd1441ec22dc749709698157ca42df36840af48467e3ddfce3663a86e28ea9c2b30d2986f36772def
|
|
7
|
+
data.tar.gz: bc09e4814a9787cc9b8427710a3215086fa930e989e593d51a87463db253420ca61aea1cecbaed1c41b806cd9ccb6fdff4e01b0abff7c772966282ec010a938c
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
plugins:
|
|
2
|
+
- rubocop-performance
|
|
3
|
+
- rubocop-rspec
|
|
4
|
+
|
|
5
|
+
AllCops:
|
|
6
|
+
TargetRubyVersion: 3.2
|
|
7
|
+
NewCops: enable
|
|
8
|
+
SuggestExtensions: false
|
|
9
|
+
Exclude:
|
|
10
|
+
- tmp/**/*
|
|
11
|
+
- vendor/**/*
|
|
12
|
+
- pkg/**/*
|
|
13
|
+
|
|
14
|
+
Naming/FileName:
|
|
15
|
+
Exclude:
|
|
16
|
+
- lib/sidekiq-buffered.rb
|
|
17
|
+
|
|
18
|
+
Style/Documentation:
|
|
19
|
+
Enabled: false
|
|
20
|
+
|
|
21
|
+
Style/FrozenStringLiteralComment:
|
|
22
|
+
Enabled: false
|
|
23
|
+
|
|
24
|
+
Style/StringLiterals:
|
|
25
|
+
EnforcedStyle: double_quotes
|
|
26
|
+
|
|
27
|
+
Style/TrailingCommaInArrayLiteral:
|
|
28
|
+
EnforcedStyleForMultiline: consistent_comma
|
|
29
|
+
|
|
30
|
+
Style/TrailingCommaInHashLiteral:
|
|
31
|
+
EnforcedStyleForMultiline: consistent_comma
|
|
32
|
+
|
|
33
|
+
Style/TrailingCommaInArguments:
|
|
34
|
+
EnforcedStyleForMultiline: consistent_comma
|
|
35
|
+
|
|
36
|
+
Layout/FirstArrayElementIndentation:
|
|
37
|
+
EnforcedStyle: consistent
|
|
38
|
+
|
|
39
|
+
Gemspec/DevelopmentDependencies:
|
|
40
|
+
Enabled: false
|
|
41
|
+
|
|
42
|
+
Metrics/AbcSize:
|
|
43
|
+
Max: 25
|
|
44
|
+
|
|
45
|
+
Metrics/ClassLength:
|
|
46
|
+
Enabled: false
|
|
47
|
+
|
|
48
|
+
Metrics/MethodLength:
|
|
49
|
+
Max: 30
|
|
50
|
+
|
|
51
|
+
RSpec/MultipleExpectations:
|
|
52
|
+
Enabled: false
|
|
53
|
+
|
|
54
|
+
RSpec/ExampleLength:
|
|
55
|
+
Max: 20
|
|
56
|
+
|
|
57
|
+
RSpec/NestedGroups:
|
|
58
|
+
Enabled: false
|
|
59
|
+
|
|
60
|
+
RSpec/DescribeClass:
|
|
61
|
+
Enabled: false
|
|
62
|
+
|
|
63
|
+
Metrics/ParameterLists:
|
|
64
|
+
CountKeywordArgs: false
|
data/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby 4.0.6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-09-20
|
|
8
|
+
|
|
9
|
+
First release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Sidekiq::Buffered` mixin with a `buffered` declaration that takes `max_size`, `max_wait`,
|
|
14
|
+
an optional `group_by` key, and an optional per-job `lease_timeout`.
|
|
15
|
+
- Buffering through the native `perform_async`, `perform_bulk`, and `perform_in` APIs, which
|
|
16
|
+
keep their argument shapes and still return input JIDs.
|
|
17
|
+
- Batches dispatched as `Sidekiq::Buffered::Processor` jobs that name the original job class
|
|
18
|
+
as `wrapped` and `display_class`, so Sidekiq logs, metrics, and Web attribute them correctly.
|
|
19
|
+
- A poller that flushes buffers whose `max_wait` has elapsed and a heartbeat that keeps
|
|
20
|
+
running batches leased, both started inside Sidekiq server processes.
|
|
21
|
+
- Redis lease-based claiming with recovery of batches whose worker died, retry of failed
|
|
22
|
+
batches under the inputs' retry options, and manual replay of exhausted batches.
|
|
23
|
+
- Global settings for `poll_interval`, `lease_timeout`, `recovery_interval`,
|
|
24
|
+
`recovery_max_interval`, and `failed_retention`.
|
|
25
|
+
- Read-only batch context through `buffered_batch` inside `perform` and
|
|
26
|
+
`Sidekiq::Buffered.current_batch` in server middleware.
|
|
27
|
+
- Batch inspection, statistics, lifecycle events, and buffer controls.
|
|
28
|
+
- Active Job support and scheduled inputs.
|
|
29
|
+
- Sidekiq testing support: fake-mode `drain` and `perform_one` execute buffered batches, and
|
|
30
|
+
`perform_inline` runs an explicit batch.
|
|
31
|
+
|
|
32
|
+
[0.1.0]: https://github.com/ksarunas/sidekiq-buffered/releases/tag/v0.1.0
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sarunas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# sidekiq-buffered
|
|
2
|
+
|
|
3
|
+
Buffer individually enqueued Sidekiq job inputs and invoke `perform` once with an array of
|
|
4
|
+
those inputs.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
Add the gem to your application and require it where Sidekiq is configured:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require "sidekiq-buffered"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requiring the gem prepends a client middleware and a server middleware to Sidekiq's chains
|
|
15
|
+
and, inside a Sidekiq server process, starts two background threads: a poller that flushes
|
|
16
|
+
buffers whose wait has elapsed, and a heartbeat that keeps running batches leased. Nothing
|
|
17
|
+
else changes for jobs that do not declare `buffered`. The gem uses only Sidekiq's public
|
|
18
|
+
extension points and does not reopen Sidekiq or Active Job classes; see
|
|
19
|
+
[Design notes](#design-notes) for the list.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
class JukeboxJob
|
|
25
|
+
include Sidekiq::Job
|
|
26
|
+
include Sidekiq::Buffered
|
|
27
|
+
|
|
28
|
+
buffered(
|
|
29
|
+
max_size: 50,
|
|
30
|
+
max_wait: 2,
|
|
31
|
+
group_by: :diner,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def perform(items)
|
|
35
|
+
diner = items.first.fetch("diner")
|
|
36
|
+
tracks = items.map { |item| item.fetch("track") }
|
|
37
|
+
play_on_jukebox(diner, tracks)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
JukeboxJob.perform_async(
|
|
42
|
+
diner: "mel's",
|
|
43
|
+
track: { title: "Rebel Rebel", artist: "David Bowie" },
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
JukeboxJob.perform_bulk([
|
|
47
|
+
[{ diner: "mel's", track: { title: "Blitzkrieg Bop" } }],
|
|
48
|
+
[{ diner: "peachy's", track: { title: "Teenage Kicks" } }],
|
|
49
|
+
])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Each enqueue takes one Hash. `perform` receives an array of those hashes, string-keyed, from a
|
|
53
|
+
single group. `perform_async` and `perform_bulk` keep their native argument shapes and still
|
|
54
|
+
return input JIDs. `batch_size:` remains Sidekiq's enqueue chunking; `max_size:` is the
|
|
55
|
+
execution batch size.
|
|
56
|
+
|
|
57
|
+
Inside `perform`, `buffered_batch` exposes a read-only context with `id`, `job_class`,
|
|
58
|
+
`group`, `input_jids`, and `attempts`. Use the batch or input IDs for idempotency keys and
|
|
59
|
+
correlation. Server middleware can read the same context as `Sidekiq::Buffered.current_batch`;
|
|
60
|
+
it is cleared after execution, including on exceptions.
|
|
61
|
+
|
|
62
|
+
Calling `Sidekiq::Client.push` or `push_bulk` directly with a buffered class still buffers the
|
|
63
|
+
inputs, but Sidekiq returns `nil` for pushes its client middleware diverts, and each input is
|
|
64
|
+
admitted in its own Redis call. Use the job class methods or `Sidekiq::Buffered::Client` to
|
|
65
|
+
keep input JIDs and atomic chunk admission.
|
|
66
|
+
|
|
67
|
+
## How a batch moves
|
|
68
|
+
|
|
69
|
+
1. **Admission.** Enqueueing writes each input into a Redis buffer. A buffer holds inputs of
|
|
70
|
+
one job class, one grouping value, and one set of execution options and middleware
|
|
71
|
+
metadata. All immediate inputs in a Sidekiq enqueue chunk are admitted in one Redis script
|
|
72
|
+
call.
|
|
73
|
+
2. **Materialization.** When a buffer reaches `max_size`, the script immediately cuts a batch
|
|
74
|
+
from it. Otherwise the poller cuts a batch once `max_wait` has elapsed since the buffer's
|
|
75
|
+
first input.
|
|
76
|
+
3. **Dispatch.** Each batch is queued as a `Sidekiq::Buffered::Processor` job whose only
|
|
77
|
+
argument is the batch ID, on the inputs' queue and with their retry options. The payload
|
|
78
|
+
names the original job class as its `wrapped` and `display_class`, so Sidekiq's logs,
|
|
79
|
+
metrics, and Web attribute the batch to that class.
|
|
80
|
+
4. **Claim and execution.** The server middleware claims the batch under a Redis lease before
|
|
81
|
+
the rest of the server chain runs, then the processor calls `perform(items)`.
|
|
82
|
+
5. **Acknowledgement.** After the chain succeeds, the middleware acknowledges the batch, which
|
|
83
|
+
deletes it. A batch that fails stays in Redis for retry, recovery, or manual replay.
|
|
84
|
+
|
|
85
|
+
Delivery is at-least-once: a crash after `perform` succeeded but before acknowledgement runs
|
|
86
|
+
the batch again, and a failure after `perform` in downstream middleware may repeat application
|
|
87
|
+
side effects. Keep application work idempotent on the batch or input IDs.
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
### Per job
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
buffered max_size: 50, max_wait: 2, group_by: :diner, lease_timeout: 120
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
| Option | Required | Meaning |
|
|
98
|
+
|-----------------|----------|--------------------------------------------------------------------------------------|
|
|
99
|
+
| `max_size` | yes | Inputs per batch. A full buffer is cut into a batch immediately. |
|
|
100
|
+
| `max_wait` | yes | Seconds a partial buffer waits, from its first input, before the poller flushes it. |
|
|
101
|
+
| `group_by` | no | Argument key whose value separates buffers. Every input must carry it. |
|
|
102
|
+
| `lease_timeout` | no | Overrides the global lease timeout for this job's batches (see below). |
|
|
103
|
+
|
|
104
|
+
The declaration is inherited by subclasses; redeclaring `buffered` overrides it for that
|
|
105
|
+
subclass only. Each concrete job class has its own buffers.
|
|
106
|
+
|
|
107
|
+
Inputs with different Sidekiq execution options (`queue`, `retry`, `retry_for`,
|
|
108
|
+
`retry_queue`, `dead`, `backtrace`) form separate buffers, and a batch runs with the options
|
|
109
|
+
its inputs were enqueued with. Metadata attached by other client middleware, such as
|
|
110
|
+
persisted `CurrentAttributes`, trace fields, and tags, also separates buffers and is retained
|
|
111
|
+
for execution and replay, so a batch never adopts the last producer's tenant context.
|
|
112
|
+
Request-specific trace IDs therefore reduce batch sizes. Server middleware still wraps the
|
|
113
|
+
processor once per batch. The `buffered_context` and `buffered_active_job_context` payload
|
|
114
|
+
fields are reserved for the gem.
|
|
115
|
+
|
|
116
|
+
### Global
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
Sidekiq::Buffered.lease_timeout = 60
|
|
120
|
+
Sidekiq::Buffered.failed_retention = 7 * 24 * 60 * 60
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Setting | Default | Meaning |
|
|
124
|
+
|-------------------------|---------|---------------------------------------------------------------------------------------------------------|
|
|
125
|
+
| `poll_interval` | `0.25` | Target seconds between poller ticks across the whole cluster. Positive number. |
|
|
126
|
+
| `lease_timeout` | `60` | Seconds until a crashed worker's batch becomes claimable again. Positive Integer. |
|
|
127
|
+
| `recovery_interval` | `30` | Seconds after dispatch before recovery first checks whether a processor is still queued. Positive Integer. |
|
|
128
|
+
| `recovery_max_interval` | `300` | Upper bound for the recovery check backoff. Positive Integer. |
|
|
129
|
+
| `failed_retention` | `nil` | Seconds to keep exhausted batches before deleting them, or `nil` to keep them until replayed. |
|
|
130
|
+
|
|
131
|
+
`poll_interval` is a cluster target: each process stretches its own idle interval by the
|
|
132
|
+
number of Sidekiq processes, with random jitter, the way Sidekiq's scheduler does, and shortens
|
|
133
|
+
it to the next known buffer deadline or recovery check.
|
|
134
|
+
|
|
135
|
+
Because running batches are kept alive by the heartbeat, `lease_timeout` bounds recovery delay
|
|
136
|
+
after a worker dies, not how long `perform` may run. A job-level override is fixed when its
|
|
137
|
+
batch materializes and also extends that batch's retry protection window.
|
|
138
|
+
|
|
139
|
+
Cleanup under `failed_retention` permanently removes the gem's stored payloads and their
|
|
140
|
+
outstanding counts. It is separate from Sidekiq's dead-job retention. Retained failures keep
|
|
141
|
+
counting in `Sidekiq::Buffered.stats`, so inspect and resolve them rather than letting them
|
|
142
|
+
accumulate.
|
|
143
|
+
|
|
144
|
+
The gem imposes no cap on buffered inputs. A backlog lives in Redis like any Sidekiq queue
|
|
145
|
+
backlog; watch `outstanding_inputs` and `stored_bytes` in `Sidekiq::Buffered.stats` and alert
|
|
146
|
+
on them the way you alert on queue depth.
|
|
147
|
+
|
|
148
|
+
## Failures and recovery
|
|
149
|
+
|
|
150
|
+
**Only one worker runs a batch at a time.** A Redis lease makes the claim exclusive. Each
|
|
151
|
+
Sidekiq process runs a heartbeat thread that extends the leases of batches it is executing at
|
|
152
|
+
a third of the lease timeout. If the heartbeat cannot reach Redis for a whole lease timeout,
|
|
153
|
+
the lease expires and another worker can claim the batch. The original worker logs
|
|
154
|
+
`lease lost`, emits a `lease_lost` event, and its acknowledgement is refused.
|
|
155
|
+
|
|
156
|
+
**Failures retry through Sidekiq.** A processor retry is a Sidekiq retry of the same job, with
|
|
157
|
+
the same JID. On failure the gem records the error and keeps the batch out of recovery for
|
|
158
|
+
Sidekiq's maximum default retry delay plus the lease timeout, so recovery never races a retry
|
|
159
|
+
that Sidekiq is about to run. The retrying processor itself can reclaim immediately; the
|
|
160
|
+
window is not a minimum execution time. A failure callback from an earlier execution JID
|
|
161
|
+
cannot overwrite a newer owner's state. Failures in downstream middleware, before or after
|
|
162
|
+
`perform`, retain the batch for retry. Middleware that does not yield leaves the batch
|
|
163
|
+
available for recovery rather than discarding it.
|
|
164
|
+
|
|
165
|
+
The buffered job class's own `sidekiq_retry_in` and `sidekiq_retries_exhausted` callbacks run
|
|
166
|
+
for the batch's processor payload, whose arguments contain the batch ID. A `sidekiq_retry_in`
|
|
167
|
+
callback that returns `:discard` acknowledges the batch; `:kill` sends it straight to
|
|
168
|
+
exhaustion. Sidekiq resolves these callbacks through the payload's `wrapped` class, so
|
|
169
|
+
`buffered` prepends `Sidekiq::Buffered::RetryCallbacks` to the job class's singleton; the
|
|
170
|
+
callbacks Sidekiq sees record the batch state first and then run the declared ones.
|
|
171
|
+
|
|
172
|
+
**Exhausted batches wait for you.** After Sidekiq's retries are exhausted, the batch stays in
|
|
173
|
+
Redis marked `failed` and is not revived until it is replayed. `Batch#retry!` replays it
|
|
174
|
+
immediately and removes the processor's Dead entry. Sidekiq Web's individual and bulk Dead-set
|
|
175
|
+
retry actions also work: they re-enqueue the exhausted processor under its original JID, the
|
|
176
|
+
gem recognizes the redelivery on claim and replays the batch, and the batch stays `failed` in
|
|
177
|
+
inspection until a worker picks it up. A stale Dead entry for a batch that already completed
|
|
178
|
+
or was replayed elsewhere is a no-op. `failed_retention` deletes exhausted batches
|
|
179
|
+
automatically when set.
|
|
180
|
+
|
|
181
|
+
**Shutdown releases the lease.** When Sidekiq shuts down while a batch is running, it
|
|
182
|
+
interrupts `perform` and requeues the processor. The gem releases the lease at that point so
|
|
183
|
+
the requeued processor reclaims the batch as soon as a worker picks it up.
|
|
184
|
+
|
|
185
|
+
**Crashes are recovered by the poller.** A batch whose processor disappeared, for example
|
|
186
|
+
because the queue was flushed or the worker died before claiming, is re-dispatched once its
|
|
187
|
+
recovery deadline passes. Before re-enqueueing, recovery checks whether the dispatched
|
|
188
|
+
processor is still waiting in its queue. Repeated checks back off from `recovery_interval` to
|
|
189
|
+
`recovery_max_interval`. Recovered batch IDs are logged at info level. Buffer deadlines,
|
|
190
|
+
lease expiry, and recovery decisions all use Redis time, so producers and workers with skewed
|
|
191
|
+
clocks agree; Sidekiq's own scheduler controls when scheduled inputs are admitted.
|
|
192
|
+
|
|
193
|
+
## Inspecting batches
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
batch = Sidekiq::Buffered::Batch.find(batch_id)
|
|
197
|
+
batch.items
|
|
198
|
+
batch.input_jids
|
|
199
|
+
batch.state # "queued", "running", "retrying", or "failed"
|
|
200
|
+
batch.attempts # Successful claims since creation or manual replay
|
|
201
|
+
batch.last_error # { "class" => "RuntimeError", "message" => "...", "at" => 1789750000.0 }
|
|
202
|
+
batch.failed_at # Unix timestamp when retries were exhausted, or nil
|
|
203
|
+
batch.retry!
|
|
204
|
+
batch.discard! # Permanently remove an exhausted batch; state becomes "discarded"
|
|
205
|
+
|
|
206
|
+
Sidekiq::Buffered::Batch.failed(job_class: JukeboxJob, group: "mel's", limit: 20, offset: 20)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`Batch.find` returns a snapshot; call `reload` or fetch again to refresh it. A successful
|
|
210
|
+
delivery removes the batch, so this API is not a history of completed work. Manual replay
|
|
211
|
+
clears the last error and attempt count. Only exhausted batches can be discarded; discarding
|
|
212
|
+
removes the batch's inputs from the `stats` counts and deletes the processor's Dead entry.
|
|
213
|
+
Error details retain the exception class, at most 2,048 UTF-8 bytes of its message, and the
|
|
214
|
+
failure time; they do not include backtraces. Exception messages can contain application
|
|
215
|
+
data, so treat inspection access like access to Sidekiq job payloads.
|
|
216
|
+
|
|
217
|
+
Batch states are the latest recorded lifecycle state: `queued` includes batches waiting for
|
|
218
|
+
dispatch, `running` means a worker claimed the batch, `retrying` means a failure is awaiting
|
|
219
|
+
retry, and `failed` means retries were exhausted. A crashed worker's batch stays `running`
|
|
220
|
+
until recovery. States are not live scans of Sidekiq's queues or proof that a worker is alive.
|
|
221
|
+
|
|
222
|
+
### Statistics
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
Sidekiq::Buffered.stats # Optional job_class: and group: filters
|
|
226
|
+
# => {
|
|
227
|
+
# outstanding_inputs: 187,
|
|
228
|
+
# stored_bytes: 42000,
|
|
229
|
+
# pending_inputs: 23,
|
|
230
|
+
# oldest_pending_age: 1.4,
|
|
231
|
+
# oldest_input_age: 62.1,
|
|
232
|
+
# batches: { queued: 2, running: 1, retrying: 3, failed: 1 }
|
|
233
|
+
# }
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`outstanding_inputs` and `stored_bytes` include partial buffers and unacknowledged batches,
|
|
237
|
+
failures included. Inputs shared by separate middleware contexts are counted once.
|
|
238
|
+
`pending_inputs` counts inputs still in partial buffers, excluding materialized batches.
|
|
239
|
+
`oldest_pending_age` is the age in seconds of the oldest such input; `oldest_input_age` also
|
|
240
|
+
covers queued, running, retrying, and failed batches. Ages are `nil` when no timestamp is
|
|
241
|
+
available, which can be the case for inputs buffered before timestamp tracking existed.
|
|
242
|
+
|
|
243
|
+
Inspection is read-only and pages through Redis indexes in groups of 100 with pipelined
|
|
244
|
+
reads. Its cost grows with the backlog, so use it for periodic operational checks rather than
|
|
245
|
+
on every enqueue. Results are best-effort snapshots while work changes concurrently.
|
|
246
|
+
|
|
247
|
+
### Events
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
Sidekiq::Buffered.on_event = ->(event, details) { Metrics.increment("buffered.#{event}") }
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Events are `appended`, `claimed`, `claim_skipped`, `acknowledged`, `acknowledgement_lost`,
|
|
254
|
+
`lease_lost`, `released`, `retrying`, `failed`, `recovered`, and `discarded`. Details contain
|
|
255
|
+
identifiers and counts, not input payloads. Handlers run synchronously, must be thread-safe,
|
|
256
|
+
and should return quickly; handler exceptions are logged without changing delivery. Assign
|
|
257
|
+
`nil` to disable the handler.
|
|
258
|
+
|
|
259
|
+
## Buffer controls
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
JukeboxJob.flush(group: "mel's") # Dispatch partial buffers now; returns batch IDs
|
|
263
|
+
Sidekiq::Buffered.flush(job_class: JukeboxJob) # All groups for this class
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Flush and failed-list pagination are best-effort under concurrent changes. Grouping controls
|
|
267
|
+
batch membership only; it does not serialize batches from the same group or guarantee their
|
|
268
|
+
completion order.
|
|
269
|
+
|
|
270
|
+
## Active Job
|
|
271
|
+
|
|
272
|
+
Active Job support is opt-in. Add `activejob` (or Rails) to your application and select this
|
|
273
|
+
gem's adapter on each buffered job:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
require "active_job/queue_adapters/sidekiq_buffered_adapter"
|
|
277
|
+
|
|
278
|
+
class JukeboxJob < ActiveJob::Base
|
|
279
|
+
include Sidekiq::Buffered
|
|
280
|
+
|
|
281
|
+
self.queue_adapter = :sidekiq_buffered
|
|
282
|
+
buffered max_size: 50, max_wait: 2, group_by: :diner
|
|
283
|
+
|
|
284
|
+
def perform(items)
|
|
285
|
+
play_on_jukebox(items.first.fetch(:diner), items.map { |item| item.fetch(:track) })
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
JukeboxJob.perform_later(diner: "mel's", track: "Rebel Rebel")
|
|
290
|
+
JukeboxJob.set(wait: 5.minutes).perform_later(diner: "mel's", track: "Later")
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Use `:sidekiq_buffered` only for jobs that declare `buffered`; other jobs keep their existing
|
|
294
|
+
adapter. Including `Sidekiq::Buffered` under the ordinary `:sidekiq` adapter does not enable
|
|
295
|
+
buffering. Active Job is an optional dependency and is not loaded for native Sidekiq jobs.
|
|
296
|
+
|
|
297
|
+
Each enqueue accepts one Hash. Active Job serializes inputs, preserving symbol keys,
|
|
298
|
+
GlobalIDs, and its other supported argument types. A batch executes through
|
|
299
|
+
`ActiveJob::Base.execute` as **one Active Job** with an array of deserialized input hashes,
|
|
300
|
+
so perform callbacks, instrumentation, `rescue_from`, `retry_on`, and `discard_on` apply once
|
|
301
|
+
to the whole batch. Enqueue callbacks apply to each input. `perform_now` remains ordinary,
|
|
302
|
+
unbuffered Active Job execution and expects the array that `perform` takes.
|
|
303
|
+
|
|
304
|
+
The batch's Active Job `job_id` is the batch ID and its `provider_job_id` is the processor
|
|
305
|
+
JID. Input jobs keep their own job IDs and receive individual Sidekiq provider JIDs. Inputs
|
|
306
|
+
with different grouping values, queues, locales, or timezones do not share a batch; the batch
|
|
307
|
+
inherits its first input's other Active Job metadata. Batches execute on the inputs' queue and
|
|
308
|
+
Active Job retries use their selected queue. Numeric Active Job priority does not control
|
|
309
|
+
Sidekiq execution order.
|
|
310
|
+
|
|
311
|
+
`retry_on` schedules the complete batch through a Sidekiq `ActiveJobRetry` wrapper without
|
|
312
|
+
rebuffering it. Once that enqueue succeeds the original stored batch is acknowledged, and
|
|
313
|
+
subsequent retries and terminal failures live in Sidekiq, outside buffered batch inspection
|
|
314
|
+
and retention. The gem observes Active Job's `enqueue_retry.active_job` instrumentation; a
|
|
315
|
+
retry that was announced but not accepted by Sidekiq, for example because an enqueue callback
|
|
316
|
+
aborted or a client middleware rejected it, raises `Sidekiq::Buffered::RetryEnqueueError` and
|
|
317
|
+
keeps the source batch for Sidekiq retry. `discard_on` also acknowledges the batch. Unhandled
|
|
318
|
+
exceptions keep the original batch in the gem's Sidekiq retry and replay lifecycle. Each such
|
|
319
|
+
processor attempt starts a fresh Active Job execution; Active Job's execution counter carries
|
|
320
|
+
forward only across Active Job retries. Delivery remains at-least-once.
|
|
321
|
+
|
|
322
|
+
On Active Job 7.2 and later the adapter inherits `ActiveJob::QueueAdapters::AbstractAdapter`
|
|
323
|
+
and answers `enqueue_after_transaction_commit?` with `true`, like Sidekiq's own adapter, so
|
|
324
|
+
jobs enqueued inside a transaction are buffered after it commits when Rails asks. Scheduled
|
|
325
|
+
Active Job inputs follow the delay-first behavior described next.
|
|
326
|
+
|
|
327
|
+
## Scheduled inputs
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
JukeboxJob.perform_in(5 * 60, diner: "mel's", track: { title: "Later" })
|
|
331
|
+
JukeboxJob.perform_at(Time.now + 300, diner: "mel's", track: { title: "Later" })
|
|
332
|
+
JukeboxJob.set(at: Time.now.to_f + 300).perform_bulk([
|
|
333
|
+
[{ diner: "mel's", track: { title: "One" } }],
|
|
334
|
+
[{ diner: "mel's", track: { title: "Two" } }],
|
|
335
|
+
])
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Scheduling is **delay first, buffer second**. Each scheduled input is stored as a
|
|
339
|
+
`Sidekiq::Buffered::ScheduledInput` admission job in Sidekiq's scheduled set. When it is due,
|
|
340
|
+
Sidekiq queues it and a worker admits the original argument hash to the buffer, keeping the
|
|
341
|
+
original input JID. A past time follows the immediate buffering path. Bulk scheduling supports
|
|
342
|
+
per-input timestamps through `Sidekiq::Client.push_bulk`'s `at` array; inputs with identical
|
|
343
|
+
timestamps have no guaranteed admission order. Scheduled inputs are handed to Sidekiq's
|
|
344
|
+
schedule one by one rather than in bulk chunks.
|
|
345
|
+
|
|
346
|
+
A new buffer's `max_wait` starts at admission, not at the scheduling call. If the group already
|
|
347
|
+
has buffered inputs, the scheduled input joins that buffer without resetting its deadline. A
|
|
348
|
+
full batch can run immediately after admission. Execution can be later than the requested time
|
|
349
|
+
because of Sidekiq's scheduler polling, queue load, and the batch wait.
|
|
350
|
+
|
|
351
|
+
Scheduling options such as queue and retry count are preserved for the admission job, and batch
|
|
352
|
+
execution preserves them through the processor. Workers must consume the admission job's queue.
|
|
353
|
+
Admission jobs do not invoke the original job class's retry callbacks. Until admitted,
|
|
354
|
+
scheduled inputs do not appear in `Sidekiq::Buffered.stats`. If admission fails, Sidekiq
|
|
355
|
+
applies the payload's retry settings; inspect waiting admissions in Sidekiq's Scheduled,
|
|
356
|
+
Queues, Retry, or Dead views. Exhausted admissions never entered a batch and follow Sidekiq's
|
|
357
|
+
dead-job settings, including an explicit `retry: false` or `dead: false`; the gem's
|
|
358
|
+
`failed_retention` does not apply to them. A failure after buffer admission can admit an input
|
|
359
|
+
again, so application processing should tolerate duplicates.
|
|
360
|
+
|
|
361
|
+
## Testing your jobs
|
|
362
|
+
|
|
363
|
+
In Sidekiq fake mode, inputs stay in the original job's `.jobs` array and nothing is written
|
|
364
|
+
to Redis. `MyBufferedJob.drain` groups those inputs and executes full and partial batches
|
|
365
|
+
deterministically; `perform_one` executes one full or partial batch. A failed drain leaves
|
|
366
|
+
unattempted inputs queued, following Sidekiq's testing semantics. Inline mode executes each
|
|
367
|
+
enqueue call immediately, splitting bulk inputs by group, context, and `max_size`, and does not
|
|
368
|
+
wait for `max_wait`.
|
|
369
|
+
|
|
370
|
+
`perform_inline(hash)` executes a one-item batch and `perform_inline([hash, ...])` executes an
|
|
371
|
+
explicit batch. Leases, timing, and retries need integration tests with Sidekiq testing
|
|
372
|
+
disabled and a dedicated Redis instance.
|
|
373
|
+
|
|
374
|
+
## Design notes
|
|
375
|
+
|
|
376
|
+
**Sidekiq extension points.** The gem uses only public hooks:
|
|
377
|
+
|
|
378
|
+
- `Sidekiq::Buffered::ClientMiddleware` is prepended to the client middleware chain so it runs
|
|
379
|
+
after the rest of the chain. It validates and normalizes buffered inputs, captures the
|
|
380
|
+
metadata other client middleware attach, and diverts immediate inputs into Redis by halting
|
|
381
|
+
the push. Scheduled inputs are rewritten into `ScheduledInput` admission jobs.
|
|
382
|
+
- `buffered` sets the job's `client_class` Sidekiq option to `Sidekiq::Buffered::Client`, a
|
|
383
|
+
`Sidekiq::Client` subclass that admits each `perform_bulk` chunk atomically and returns input
|
|
384
|
+
JIDs. Leave `client_class` unset on buffered jobs, or point it at a subclass of this client.
|
|
385
|
+
- `buffered` also overrides `set`, `perform_inline`, `perform_sync`, and `perform_bulk` on the
|
|
386
|
+
job class so synchronous execution accepts one input hash and bypasses buffering, and
|
|
387
|
+
prepends `Sidekiq::Buffered::RetryCallbacks` to the class's singleton so Sidekiq's `wrapped`
|
|
388
|
+
callback lookup reaches the batch's retry bookkeeping.
|
|
389
|
+
- `Sidekiq::Buffered::ServerMiddleware` is prepended to the server middleware chain. It claims
|
|
390
|
+
the batch, registers it with the heartbeat, and releases the lease on `Sidekiq::Shutdown`.
|
|
391
|
+
Keep it first if you customize middleware order.
|
|
392
|
+
- `Sidekiq::Buffered::Processor` and `Sidekiq::Buffered::ScheduledInput` are ordinary Sidekiq
|
|
393
|
+
jobs, and Active Job support is an ordinary queue adapter.
|
|
394
|
+
|
|
395
|
+
**Poller ticks.** A tick is one Redis script that returns the Redis time and everything due.
|
|
396
|
+
Due buffers are then flushed in a single pipeline, up to 500 per tick, and a full page
|
|
397
|
+
triggers an immediate follow-up tick. Consecutive poller errors back off exponentially to 30
|
|
398
|
+
seconds; a successful tick restores the normal interval.
|
|
399
|
+
|
|
400
|
+
**Recovery and queue scans.** Recovery needs the exact processor payload Sidekiq queued in
|
|
401
|
+
order to check the queue with Redis `LPOS`. The gem's own dispatch captures it through the
|
|
402
|
+
client middleware; processors that Sidekiq itself re-enqueues from its retry or Dead sets are
|
|
403
|
+
not recorded, so an extreme backlog after such a re-push can dispatch a duplicate processor.
|
|
404
|
+
Duplicates are harmless because only one claim succeeds. `LPOS` cost grows with queue length,
|
|
405
|
+
and the recovery backoff limits repeated scans during a sustained backlog.
|
|
406
|
+
|
|
407
|
+
**Identifiers and compatibility.** Batch IDs are opaque strings combining a random UUID
|
|
408
|
+
namespace with a sequence, so independent Redis datasets never share small integer IDs.
|
|
409
|
+
Low-level `Sidekiq::Client` payloads use Sidekiq's string-keyed contract; ordinary job
|
|
410
|
+
argument hashes can use symbol keys. Redis Cluster is not supported; use a standalone Redis
|
|
411
|
+
deployment suitable for the selected Sidekiq version.
|
|
412
|
+
|
|
413
|
+
## Development
|
|
414
|
+
|
|
415
|
+
Run `bundle install`, then:
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
REDIS_URL=redis://localhost:6379/15 bundle exec rake
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
That runs RSpec and RuboCop. The specs use a real Redis instance and clear the selected test
|
|
422
|
+
database before and after each example, and stop the poller and heartbeat threads after every
|
|
423
|
+
example. Use a dedicated test Redis (`REDIS_URL`) and optionally `SIDEKIQ_BUFFERED_TEST_DB`
|
|
424
|
+
(default 15); never point the suite at application data. The lifecycle specs run the actual
|
|
425
|
+
Sidekiq processor, server middleware, retry handler, and scheduled retry enqueuer, advancing
|
|
426
|
+
the clock rather than sleeping through retry backoff.
|
|
427
|
+
|
|
428
|
+
CI covers Ruby 3.2 with Sidekiq 7.0 and Active Job 7.0, Ruby 3.3 with Sidekiq 7.3 and Active
|
|
429
|
+
Job 7.2, Ruby 3.4 with Sidekiq 8.0 and Active Job 8.0, and Ruby 4.0 with Sidekiq 8.1 and
|
|
430
|
+
Active Job 8.1. Set `SIDEKIQ_VERSION` and `ACTIVEJOB_VERSION` when resolving a bundle to
|
|
431
|
+
select another combination. The Active Job bulk-enqueue test is skipped on Active Job 7.0,
|
|
432
|
+
which has no `perform_all_later` API.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require "active_job"
|
|
2
|
+
require "sidekiq-buffered"
|
|
3
|
+
require "sidekiq/buffered/active_job_execution"
|
|
4
|
+
require "sidekiq/buffered/active_job_retry"
|
|
5
|
+
|
|
6
|
+
module ActiveJob
|
|
7
|
+
module QueueAdapters
|
|
8
|
+
# Active Job 7.2 introduced AbstractAdapter and asks adapters whether jobs
|
|
9
|
+
# enqueued inside a transaction should wait for its commit. Buffering after
|
|
10
|
+
# commit is the safe answer, matching Sidekiq's own adapter.
|
|
11
|
+
SIDEKIQ_BUFFERED_ADAPTER_BASE = defined?(AbstractAdapter) ? AbstractAdapter : Object
|
|
12
|
+
|
|
13
|
+
class SidekiqBufferedAdapter < SIDEKIQ_BUFFERED_ADAPTER_BASE
|
|
14
|
+
def enqueue(job)
|
|
15
|
+
return enqueue_retry(job) if job.executions.positive?
|
|
16
|
+
|
|
17
|
+
enqueue_input(job)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enqueue_at(job, timestamp)
|
|
21
|
+
return enqueue_retry(job, timestamp) if job.executions.positive?
|
|
22
|
+
|
|
23
|
+
enqueue_input(job, timestamp)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def enqueue_retry(job, timestamp = nil)
|
|
29
|
+
wrapper = Sidekiq::Buffered::ActiveJobRetry.set(wrapped: job.class, queue: job.queue_name)
|
|
30
|
+
jid = if timestamp
|
|
31
|
+
wrapper.perform_at(timestamp, job.serialize)
|
|
32
|
+
else
|
|
33
|
+
wrapper.perform_async(job.serialize)
|
|
34
|
+
end
|
|
35
|
+
raise ActiveJob::EnqueueError, "Sidekiq client middleware rejected the buffered batch retry" if jid.nil?
|
|
36
|
+
|
|
37
|
+
Sidekiq::Buffered::ActiveJobExecution.record_handoff(job.job_id)
|
|
38
|
+
job.provider_job_id = jid
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def enqueue_input(job, timestamp = nil)
|
|
42
|
+
unless Sidekiq::Buffered.registered?(job.class)
|
|
43
|
+
raise ArgumentError, "#{job.class} must declare buffered options"
|
|
44
|
+
end
|
|
45
|
+
unless job.arguments.size == 1 && job.arguments.first.is_a?(Hash)
|
|
46
|
+
raise ArgumentError, "#{job.class} expects a single Hash argument"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
data = job.serialize
|
|
50
|
+
payload = {
|
|
51
|
+
"class" => job.class.name,
|
|
52
|
+
"queue" => job.queue_name,
|
|
53
|
+
"args" => data.fetch("arguments"),
|
|
54
|
+
"active_job" => data,
|
|
55
|
+
}
|
|
56
|
+
payload["at"] = timestamp if timestamp
|
|
57
|
+
job.provider_job_id = Sidekiq::Buffered::Client.new.push(payload)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|