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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +64 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +32 -0
  6. data/Gemfile +6 -0
  7. data/LICENSE +21 -0
  8. data/README.md +432 -0
  9. data/Rakefile +8 -0
  10. data/lib/active_job/queue_adapters/sidekiq_buffered_adapter.rb +61 -0
  11. data/lib/sidekiq/buffered/active_job_execution.rb +49 -0
  12. data/lib/sidekiq/buffered/active_job_retry.rb +12 -0
  13. data/lib/sidekiq/buffered/batch.rb +70 -0
  14. data/lib/sidekiq/buffered/batch_context.rb +37 -0
  15. data/lib/sidekiq/buffered/batch_record.rb +79 -0
  16. data/lib/sidekiq/buffered/client.rb +109 -0
  17. data/lib/sidekiq/buffered/client_middleware.rb +38 -0
  18. data/lib/sidekiq/buffered/collector.rb +63 -0
  19. data/lib/sidekiq/buffered/config.rb +26 -0
  20. data/lib/sidekiq/buffered/dispatch.rb +95 -0
  21. data/lib/sidekiq/buffered/events.rb +29 -0
  22. data/lib/sidekiq/buffered/execution.rb +143 -0
  23. data/lib/sidekiq/buffered/heartbeat.rb +96 -0
  24. data/lib/sidekiq/buffered/inline.rb +17 -0
  25. data/lib/sidekiq/buffered/input.rb +122 -0
  26. data/lib/sidekiq/buffered/periodic_thread.rb +45 -0
  27. data/lib/sidekiq/buffered/poller.rb +81 -0
  28. data/lib/sidekiq/buffered/processor.rb +42 -0
  29. data/lib/sidekiq/buffered/registry.rb +38 -0
  30. data/lib/sidekiq/buffered/retry_callbacks.rb +32 -0
  31. data/lib/sidekiq/buffered/scheduled_input.rb +21 -0
  32. data/lib/sidekiq/buffered/scope.rb +17 -0
  33. data/lib/sidekiq/buffered/script.rb +48 -0
  34. data/lib/sidekiq/buffered/scripts/ack.lua +5 -0
  35. data/lib/sidekiq/buffered/scripts/append.lua +64 -0
  36. data/lib/sidekiq/buffered/scripts/claim.lua +29 -0
  37. data/lib/sidekiq/buffered/scripts/cleanup_failed.lua +7 -0
  38. data/lib/sidekiq/buffered/scripts/discard.lua +7 -0
  39. data/lib/sidekiq/buffered/scripts/exhaust.lua +10 -0
  40. data/lib/sidekiq/buffered/scripts/extend.lua +7 -0
  41. data/lib/sidekiq/buffered/scripts/flush_due.lua +25 -0
  42. data/lib/sidekiq/buffered/scripts/helpers/delete_batch.lua +21 -0
  43. data/lib/sidekiq/buffered/scripts/helpers/materialize_batch.lua +21 -0
  44. data/lib/sidekiq/buffered/scripts/helpers/redis_time.lua +6 -0
  45. data/lib/sidekiq/buffered/scripts/record_dispatch.lua +5 -0
  46. data/lib/sidekiq/buffered/scripts/recover.lua +21 -0
  47. data/lib/sidekiq/buffered/scripts/release.lua +8 -0
  48. data/lib/sidekiq/buffered/scripts/replay.lua +11 -0
  49. data/lib/sidekiq/buffered/scripts/retrying.lua +13 -0
  50. data/lib/sidekiq/buffered/scripts/tick.lua +10 -0
  51. data/lib/sidekiq/buffered/scripts.rb +23 -0
  52. data/lib/sidekiq/buffered/server_middleware.rb +51 -0
  53. data/lib/sidekiq/buffered/setter.rb +41 -0
  54. data/lib/sidekiq/buffered/statistics.rb +130 -0
  55. data/lib/sidekiq/buffered/store.rb +363 -0
  56. data/lib/sidekiq/buffered/testing.rb +65 -0
  57. data/lib/sidekiq/buffered/thread_local.rb +14 -0
  58. data/lib/sidekiq/buffered/version.rb +5 -0
  59. data/lib/sidekiq/buffered.rb +172 -0
  60. data/lib/sidekiq-buffered.rb +1 -0
  61. data/sidekiq-buffered.gemspec +37 -0
  62. 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
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format documentation
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
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "activejob", ENV.fetch("ACTIVEJOB_VERSION") if ENV["ACTIVEJOB_VERSION"]
6
+ gem "sidekiq", ENV.fetch("SIDEKIQ_VERSION") if ENV["SIDEKIQ_VERSION"]
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,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: %i[spec rubocop]
@@ -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