sidekiq-batch-jobs 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/README.md +195 -0
- data/Rakefile +12 -0
- data/app/models/sidekiq_batch/batch_enrollment_context.rb +91 -0
- data/app/models/sidekiq_batch/client_middleware.rb +19 -0
- data/app/models/sidekiq_batch/middleware.rb +76 -0
- data/app/models/sidekiq_batch.rb +133 -0
- data/app/models/sidekiq_batch_job.rb +47 -0
- data/docker-compose.yml +26 -0
- data/lib/generators/sidekiq/batch/jobs/install/install_generator.rb +29 -0
- data/lib/generators/sidekiq/batch/jobs/install/templates/create_sidekiq_batch_tables.rb.tt +33 -0
- data/lib/sidekiq/batch/jobs/engine.rb +17 -0
- data/lib/sidekiq/batch/jobs/version.rb +9 -0
- data/lib/sidekiq/batch/jobs.rb +46 -0
- data/sig/sidekiq/batch/jobs.rbs +8 -0
- metadata +220 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e99dbc78b86da74277380f2ba6fe62c5433ab3815d23b0f756ff6173267fb168
|
|
4
|
+
data.tar.gz: 96f45bcea22e9cd2359190946e62b54d92c8c192ddfbc87dd4148ccb9abe53e1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7d665aba6579df375e91325f433cecf574abb0310ad9efd142358232890b5ddabccdac3f6d3a3a9773080f7facd7617fc4d4c0ac4116285e938e6ec805a4c15e
|
|
7
|
+
data.tar.gz: dd13456bd144b5d0a104a78b7ddf8eee1469c06a28ac497add4b78a6f4c666fa2c2151fcb2824513292b65f13af16e237bcc9e7713dec07d1d14e79ab54a509e
|
data/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# sidekiq-batch-jobs
|
|
2
|
+
|
|
3
|
+
Batch tracking and completion callbacks for Sidekiq, backed by ActiveRecord (PostgreSQL).
|
|
4
|
+
|
|
5
|
+
A hand-rolled alternative to Sidekiq Pro batches. Group a set of `perform_async` calls into a batch, persist their state in the database, and fire a callback worker when the batch finishes — success or failure.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "sidekiq-batch-jobs"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then run the install generator and migrate:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bin/rails g sidekiq:batch:jobs:install
|
|
19
|
+
bin/rails db:migrate
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The gem registers its client middleware, server middleware, and death handler automatically at boot.
|
|
23
|
+
|
|
24
|
+
To opt out (e.g. you need full control over middleware order), set this in `config/application.rb` before Rails boots:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
Sidekiq::Batch::Jobs.auto_install = false
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then either call `Sidekiq::Batch::Jobs.install!` from your own `config/initializers/sidekiq.rb`, or wire the three pieces by hand:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# config/initializers/sidekiq.rb
|
|
34
|
+
Sidekiq.configure_client do |config|
|
|
35
|
+
# Outermost on the client chain so any dedupe/suppression middleware
|
|
36
|
+
# registered later with `chain.add` gets the final say on whether a
|
|
37
|
+
# push actually happens. We only enroll jobs that the chain agrees to push.
|
|
38
|
+
config.client_middleware do |chain|
|
|
39
|
+
chain.prepend SidekiqBatch::ClientMiddleware
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Sidekiq.configure_server do |config|
|
|
44
|
+
# Same prepend for the server's own client chain — covers `perform_async`
|
|
45
|
+
# calls made from inside a running worker.
|
|
46
|
+
config.client_middleware do |chain|
|
|
47
|
+
chain.prepend SidekiqBatch::ClientMiddleware
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# `add` (not `prepend`) so this runs LAST in the server chain — outside
|
|
51
|
+
# Sidekiq's retry middleware. That way we see the terminal disposition
|
|
52
|
+
# of each attempt and only mark the row failed on the final retry.
|
|
53
|
+
config.server_middleware do |chain|
|
|
54
|
+
chain.add SidekiqBatch::Middleware
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reconciles jobs that died without re-entering middleware (SIGKILL, OOM,
|
|
58
|
+
# pod eviction). Without this, a killed job's batch row stays `pending`
|
|
59
|
+
# forever and the batch never completes.
|
|
60
|
+
config.death_handlers << ->(job, ex) { SidekiqBatch::Middleware.handle_death(job, ex) }
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
A batch is a group of Sidekiq jobs whose collective fate you care about. Once every job in the batch ends in a terminal state, a callback worker fires — exactly once. You typically use a batch when there's "fan-in" work to do after a bunch of independent jobs finish: rebuilding a derived dataset after rescoring, sending one summary email after a thousand individual notifications, marking an import as ready once all rows are processed.
|
|
67
|
+
|
|
68
|
+
### The four steps
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# 1. Create the batch. Use `context` to stash any state the callback will need —
|
|
72
|
+
# the callback only receives the batch id, not the surrounding closure, so
|
|
73
|
+
# anything you'd otherwise close over goes here.
|
|
74
|
+
batch = SidekiqBatch.create!(
|
|
75
|
+
description: "Rescore leaderboard #{leaderboard.id}",
|
|
76
|
+
context: {
|
|
77
|
+
"leaderboard_id" => leaderboard.id,
|
|
78
|
+
"triggered_by" => current_user.id,
|
|
79
|
+
"reason" => "manual rescore from admin panel",
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# 2. Register what happens when the batch finishes
|
|
84
|
+
batch.on(:complete, RebuildLeaderboardCacheWorker)
|
|
85
|
+
batch.on(:failure, AlertOpsOfFailedRescoreWorker)
|
|
86
|
+
|
|
87
|
+
# 3. Enqueue jobs *inside* the batch context — they get enrolled automatically
|
|
88
|
+
batch.jobs do
|
|
89
|
+
leaderboard.entries.find_each do |entry|
|
|
90
|
+
ScoreWorker.perform_async(entry.id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# 4. The batch is now running. Your callback worker will be invoked when
|
|
95
|
+
# the last job lands in a terminal state — possibly hours later, on a
|
|
96
|
+
# completely different worker process.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### The `context` column
|
|
100
|
+
|
|
101
|
+
`SidekiqBatch#context` is a `jsonb` column for arbitrary per-batch metadata. The gem doesn't read it — it's a slot for *you* to pass information from the code that created the batch through to the callback worker, since the callback only receives `batch_id` and has to rehydrate everything else from the database.
|
|
102
|
+
|
|
103
|
+
Useful things to stash there:
|
|
104
|
+
|
|
105
|
+
- **Foreign keys** the callback needs (`leaderboard_id`, `import_id`, `tenant_id`).
|
|
106
|
+
- **Provenance** for debugging or audit (`triggered_by`, `via`, `request_id`).
|
|
107
|
+
- **Configuration** the callback should branch on (`notify_slack: true`, `recompute_strategy: "fast"`).
|
|
108
|
+
|
|
109
|
+
Keep it small and stable — it's metadata, not a payload. If you find yourself stuffing large arrays in there, that's a sign the data should live in its own table with a `sidekiq_batch_id`.
|
|
110
|
+
|
|
111
|
+
### What `batch.jobs do … end` actually does
|
|
112
|
+
|
|
113
|
+
The block establishes a thread-local "enrollment context." While the block runs:
|
|
114
|
+
|
|
115
|
+
- Every `perform_async` / `perform_bulk` / `set(...).perform_async` call made on this thread is intercepted by the client middleware.
|
|
116
|
+
- For each pushed job, a `SidekiqBatchJob` row is written to Postgres with the job's `jid`, worker class, and args — **before** Sidekiq pushes the payload to Redis. That ordering is the whole point: when the worker later runs and the server middleware looks for a matching row, it's guaranteed to find one.
|
|
117
|
+
- When the block returns, the batch transitions from `pending` to `running` and the context is cleared.
|
|
118
|
+
- Jobs enqueued **outside** the block are not enrolled and don't count toward this batch's completion. Same for jobs that *child* workers enqueue while running — only the original thread's enqueues are captured (by design — keeps the batch's scope predictable).
|
|
119
|
+
|
|
120
|
+
You don't have to think about jid tracking, race conditions, or middleware ordering — that's the gem's job. You just write the block.
|
|
121
|
+
|
|
122
|
+
### What the callback worker receives
|
|
123
|
+
|
|
124
|
+
The callback gets one argument: the batch id. From there it can inspect the batch's full state:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class RebuildLeaderboardCacheWorker
|
|
128
|
+
include Sidekiq::Worker
|
|
129
|
+
|
|
130
|
+
def perform(batch_id)
|
|
131
|
+
batch = SidekiqBatch.find(batch_id)
|
|
132
|
+
leaderboard_id = batch.context.fetch("leaderboard_id")
|
|
133
|
+
|
|
134
|
+
Rails.logger.info "Batch #{batch.description} finished: #{batch.progress}"
|
|
135
|
+
# => Batch Rescore leaderboard 42 finished: {total: 1247, complete: 1247, failed: 0, pending: 0}
|
|
136
|
+
|
|
137
|
+
LeaderboardCacheRebuilder.run(leaderboard_id)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class AlertOpsOfFailedRescoreWorker
|
|
142
|
+
include Sidekiq::Worker
|
|
143
|
+
|
|
144
|
+
def perform(batch_id)
|
|
145
|
+
batch = SidekiqBatch.find(batch_id)
|
|
146
|
+
|
|
147
|
+
batch.failed_jobs.find_each do |bj|
|
|
148
|
+
Ops.notify(
|
|
149
|
+
"Rescore worker died: #{bj.worker_class} args=#{bj.args} " \
|
|
150
|
+
"error=#{bj.error_class}: #{bj.error_message}",
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Either `:complete` *or* `:failure` will fire — never both, never twice. `:complete` fires when every enrolled job succeeded; `:failure` fires the moment any job ends in a failed terminal state (retries exhausted, or `retry: false` workers that raised).
|
|
158
|
+
|
|
159
|
+
### Inspecting a batch from anywhere
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
batch.progress
|
|
163
|
+
# => { total: 1247, complete: 1101, failed: 3, pending: 143 }
|
|
164
|
+
|
|
165
|
+
batch.pending_jobs # ActiveRecord relation
|
|
166
|
+
batch.failed_jobs
|
|
167
|
+
batch.completed_jobs
|
|
168
|
+
|
|
169
|
+
batch.status # "pending" | "running" | "complete" | "failed"
|
|
170
|
+
batch.completed_at # nil while running, set on terminal transition
|
|
171
|
+
batch.context # the jsonb hash you stashed when creating the batch
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
After checking out the repo, run `bin/setup` to install gem dependencies. Then run the suite with:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
bin/test # starts Postgres via docker compose, then runs rspec
|
|
180
|
+
bin/test spec/models # forwards args through to rspec
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The test harness uses [Combustion](https://github.com/pat/combustion) to boot a minimal Rails app under `spec/internal/`, and a Postgres container defined in `docker-compose.yml`. The container uses an ephemeral tmpfs for its data directory, so it's safe to `docker compose down` at any time.
|
|
184
|
+
|
|
185
|
+
If you'd rather skip the wrapper, the manual flow is:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
docker compose up -d
|
|
189
|
+
bundle exec rspec
|
|
190
|
+
docker compose down
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Contributing
|
|
194
|
+
|
|
195
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/douglasgreyling/sidekiq-batch-jobs.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SidekiqBatch
|
|
4
|
+
# Scopes a block of `perform_async` calls to a specific SidekiqBatch.
|
|
5
|
+
# Active only on the enrolling thread via a thread-local reference;
|
|
6
|
+
# child jobs spawned at execution time from tracked workers are
|
|
7
|
+
# pass-through (by design).
|
|
8
|
+
#
|
|
9
|
+
# Enrollment itself is performed by `SidekiqBatch::ClientMiddleware`,
|
|
10
|
+
# which looks up the thread-local context during each client push and
|
|
11
|
+
# writes a `SidekiqBatchJob` row BEFORE Sidekiq's `raw_push` sends the
|
|
12
|
+
# job to Redis. The client middleware also sits outermost in the chain
|
|
13
|
+
# so that dedupe/suppression middleware earlier-added get the final
|
|
14
|
+
# say on whether a payload should actually be enrolled.
|
|
15
|
+
class BatchEnrollmentContext
|
|
16
|
+
THREAD_KEY = :sidekiq_batch_enrollment_context
|
|
17
|
+
TXN_BASELINE = :sidekiq_batch_enrollment_txn_baseline
|
|
18
|
+
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
class NestedError < Error; end
|
|
22
|
+
|
|
23
|
+
class TransactionError < Error; end
|
|
24
|
+
|
|
25
|
+
class EmptyEnrollmentError < Error; end
|
|
26
|
+
|
|
27
|
+
def self.current
|
|
28
|
+
Thread.current[THREAD_KEY]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Specs running inside a fixture transaction set this to the open-txn
|
|
32
|
+
# count at the start of each example; anything above the baseline is
|
|
33
|
+
# a caller-opened transaction and is rejected.
|
|
34
|
+
def self.transaction_baseline
|
|
35
|
+
Thread.current[TXN_BASELINE] || 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(batch)
|
|
39
|
+
@batch = batch
|
|
40
|
+
@inserted_count = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :batch
|
|
44
|
+
|
|
45
|
+
def run # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
46
|
+
raise NestedError, "jobs {} is already active on this thread" if self.class.current
|
|
47
|
+
# NOTE: We can't allow the context to work whilst wrapped by a transaction.
|
|
48
|
+
# For the sake of tracking progress correctly we need the state of Redis and the DB to be one to one
|
|
49
|
+
raise TransactionError, "jobs {} cannot be called inside an open ActiveRecord transaction" if in_open_transaction?
|
|
50
|
+
|
|
51
|
+
Thread.current[THREAD_KEY] = self
|
|
52
|
+
|
|
53
|
+
yield
|
|
54
|
+
|
|
55
|
+
if @inserted_count.zero?
|
|
56
|
+
# Empty block means this batch will never have work to complete.
|
|
57
|
+
# Destroy the batch row so callers who rescue the error don't leave
|
|
58
|
+
# orphan `pending` rows lying around forever.
|
|
59
|
+
@batch.destroy
|
|
60
|
+
raise EmptyEnrollmentError, "jobs {} block enrolled zero jobs"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
actual_total = @batch.sidekiq_batch_jobs.count
|
|
64
|
+
|
|
65
|
+
@batch.update!(total_jobs: actual_total, status: "running")
|
|
66
|
+
@batch.attempt_completion!
|
|
67
|
+
ensure
|
|
68
|
+
Thread.current[THREAD_KEY] = nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Called by ClientMiddleware after downstream middleware has confirmed
|
|
72
|
+
# the job will be pushed. Insert is committed immediately (no surrounding
|
|
73
|
+
# transaction) so Sidekiq's subsequent raw_push hands off a visible row.
|
|
74
|
+
def enroll(payload)
|
|
75
|
+
::SidekiqBatchJob.create!(
|
|
76
|
+
sidekiq_batch_id: @batch.id,
|
|
77
|
+
jid: payload.fetch("jid"),
|
|
78
|
+
worker_class: payload.fetch("class"),
|
|
79
|
+
args: payload.fetch("args", []),
|
|
80
|
+
status: "pending"
|
|
81
|
+
)
|
|
82
|
+
@inserted_count += 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def in_open_transaction?
|
|
88
|
+
::ActiveRecord::Base.connection.open_transactions > self.class.transaction_baseline
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SidekiqBatch
|
|
4
|
+
# Sidekiq client middleware. Registered outermost (via `chain.prepend`)
|
|
5
|
+
# so that any dedupe/suppression middleware added later with `chain.add`
|
|
6
|
+
# has already decided whether the job will be pushed by the time we
|
|
7
|
+
# inspect the yield result. We enroll only when the chain returns a
|
|
8
|
+
# truthy payload (i.e. the push is going ahead).
|
|
9
|
+
class ClientMiddleware
|
|
10
|
+
def call(_worker_class, job, _queue, _redis_pool)
|
|
11
|
+
result = yield
|
|
12
|
+
context = ::SidekiqBatch::BatchEnrollmentContext.current
|
|
13
|
+
|
|
14
|
+
context.enroll(job) if result && context
|
|
15
|
+
|
|
16
|
+
result
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SidekiqBatch
|
|
4
|
+
# Sidekiq server middleware. Registered last in the chain so it runs
|
|
5
|
+
# outside Sidekiq's retry middleware — we see the terminal disposition
|
|
6
|
+
# of every attempt.
|
|
7
|
+
#
|
|
8
|
+
# - On success: mark complete, run completion check.
|
|
9
|
+
# - On failure: only mark failed on the FINAL attempt (retries exhausted
|
|
10
|
+
# or retry: false). Non-terminal failures leave the row `pending` and
|
|
11
|
+
# skip the completion check; the next attempt will decide.
|
|
12
|
+
# - Skips gracefully for jobs with no SidekiqBatchJob row (untracked).
|
|
13
|
+
# - `mark_complete!` / `mark_failed!` are idempotent (atomic
|
|
14
|
+
# `WHERE status = 'pending'` update), so replays are no-ops.
|
|
15
|
+
class Middleware
|
|
16
|
+
DEFAULT_MAX_RETRIES = 25
|
|
17
|
+
|
|
18
|
+
def call(worker, job, _queue) # rubocop:disable Metrics/MethodLength
|
|
19
|
+
batch_job = ::SidekiqBatchJob.find_by(jid: job["jid"])
|
|
20
|
+
|
|
21
|
+
unless batch_job
|
|
22
|
+
yield
|
|
23
|
+
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
yield
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
handle_failure(worker, job, batch_job, e)
|
|
31
|
+
raise
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
handle_success(batch_job)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Also used by the Sidekiq death handler to reconcile jobs that died
|
|
38
|
+
# without re-entering middleware (SIGKILL, OOM, pod eviction, etc.).
|
|
39
|
+
def self.handle_death(job, error)
|
|
40
|
+
batch_job = ::SidekiqBatchJob.find_by(jid: job["jid"])
|
|
41
|
+
|
|
42
|
+
return unless batch_job
|
|
43
|
+
return unless batch_job.mark_failed!(error)
|
|
44
|
+
|
|
45
|
+
batch_job.sidekiq_batch.attempt_completion!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def handle_success(batch_job)
|
|
51
|
+
return unless batch_job.mark_complete!
|
|
52
|
+
|
|
53
|
+
batch_job.sidekiq_batch.attempt_completion!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_failure(worker, job, batch_job, error)
|
|
57
|
+
return unless final_attempt?(worker, job)
|
|
58
|
+
return unless batch_job.mark_failed!(error)
|
|
59
|
+
|
|
60
|
+
batch_job.sidekiq_batch.attempt_completion!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def final_attempt?(worker, job)
|
|
64
|
+
retry_opt = worker.class.get_sidekiq_options["retry"]
|
|
65
|
+
max_retries = case retry_opt
|
|
66
|
+
when false, 0 then 0
|
|
67
|
+
when Integer then retry_opt
|
|
68
|
+
else DEFAULT_MAX_RETRIES
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
retry_count = job["retry_count"] || 0
|
|
72
|
+
|
|
73
|
+
retry_count >= max_retries
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# == Schema Information
|
|
4
|
+
#
|
|
5
|
+
# Table name: sidekiq_batches
|
|
6
|
+
#
|
|
7
|
+
# id :bigint not null, primary key
|
|
8
|
+
# callback_fired_at :datetime
|
|
9
|
+
# callbacks :jsonb not null
|
|
10
|
+
# completed_at :datetime
|
|
11
|
+
# context :jsonb not null
|
|
12
|
+
# description :string
|
|
13
|
+
# status :integer default("pending"), not null
|
|
14
|
+
# total_jobs :integer default(0), not null
|
|
15
|
+
# created_at :datetime not null
|
|
16
|
+
# updated_at :datetime not null
|
|
17
|
+
#
|
|
18
|
+
class SidekiqBatch < ActiveRecord::Base
|
|
19
|
+
EVENTS = %w[complete failure].freeze
|
|
20
|
+
|
|
21
|
+
enum :status, { pending: 0, running: 1, complete: 2, failed: 3 }, suffix: :status
|
|
22
|
+
|
|
23
|
+
has_many :sidekiq_batch_jobs, dependent: :destroy
|
|
24
|
+
|
|
25
|
+
validates :total_jobs, numericality: { greater_than_or_equal_to: 0 }
|
|
26
|
+
|
|
27
|
+
def on(event, job_class)
|
|
28
|
+
event_str = event.to_s
|
|
29
|
+
raise ArgumentError, "unknown event #{event.inspect}" unless EVENTS.include?(event_str)
|
|
30
|
+
|
|
31
|
+
self.callbacks = callbacks.merge(event_str => job_class.to_s)
|
|
32
|
+
|
|
33
|
+
save!
|
|
34
|
+
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def jobs(&block)
|
|
39
|
+
raise ArgumentError, "block required" unless block_given?
|
|
40
|
+
|
|
41
|
+
BatchEnrollmentContext.new(self).run(&block)
|
|
42
|
+
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def progress
|
|
47
|
+
counts = sidekiq_batch_jobs.group(:status).count
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
total: total_jobs,
|
|
51
|
+
complete: counts.fetch("complete", 0),
|
|
52
|
+
failed: counts.fetch("failed", 0),
|
|
53
|
+
pending: counts.fetch("pending", 0)
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pending_jobs
|
|
58
|
+
sidekiq_batch_jobs.where(status: "pending")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def failed_jobs
|
|
62
|
+
sidekiq_batch_jobs.where(status: "failed")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def completed_jobs
|
|
66
|
+
sidekiq_batch_jobs.where(status: "complete")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Atomic completion check. If all enrolled jobs are terminal, transition the
|
|
70
|
+
# batch to `complete` or `failed`, fire the registered callback, and stamp
|
|
71
|
+
# `callback_fired_at`. Returns the resulting status (String) or nil if the
|
|
72
|
+
# batch is not yet ready to transition.
|
|
73
|
+
def attempt_completion!
|
|
74
|
+
sql = self.class.sanitize_sql_array([completion_sql, id])
|
|
75
|
+
|
|
76
|
+
result = self.class.connection.exec_query(sql)
|
|
77
|
+
|
|
78
|
+
return nil if result.rows.empty?
|
|
79
|
+
|
|
80
|
+
reload
|
|
81
|
+
|
|
82
|
+
event = failed_status? ? "failure" : "complete"
|
|
83
|
+
|
|
84
|
+
fire_callback(event)
|
|
85
|
+
|
|
86
|
+
status
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def fire_callback(event)
|
|
90
|
+
return nil if callback_fired_at.present?
|
|
91
|
+
|
|
92
|
+
job_class_name = callbacks[event.to_s]
|
|
93
|
+
|
|
94
|
+
return nil if job_class_name.blank?
|
|
95
|
+
|
|
96
|
+
self.class.transaction do
|
|
97
|
+
update!(callback_fired_at: Time.current)
|
|
98
|
+
job_class_name.constantize.perform_async(id)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
job_class_name
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def completion_sql # rubocop:disable Metrics/MethodLength
|
|
107
|
+
batch_statuses = self.class.statuses
|
|
108
|
+
job_statuses = SidekiqBatchJob.statuses
|
|
109
|
+
|
|
110
|
+
<<~SQL.squish
|
|
111
|
+
UPDATE sidekiq_batches
|
|
112
|
+
SET
|
|
113
|
+
status = CASE
|
|
114
|
+
WHEN EXISTS (
|
|
115
|
+
SELECT 1 FROM sidekiq_batch_jobs
|
|
116
|
+
WHERE sidekiq_batch_id = sidekiq_batches.id
|
|
117
|
+
AND status = #{job_statuses.fetch("failed")}
|
|
118
|
+
) THEN #{batch_statuses.fetch("failed")}
|
|
119
|
+
ELSE #{batch_statuses.fetch("complete")}
|
|
120
|
+
END,
|
|
121
|
+
completed_at = NOW(),
|
|
122
|
+
updated_at = NOW()
|
|
123
|
+
WHERE id = ?
|
|
124
|
+
AND status = #{batch_statuses.fetch("running")}
|
|
125
|
+
AND NOT EXISTS (
|
|
126
|
+
SELECT 1 FROM sidekiq_batch_jobs
|
|
127
|
+
WHERE sidekiq_batch_id = sidekiq_batches.id
|
|
128
|
+
AND status = #{job_statuses.fetch("pending")}
|
|
129
|
+
)
|
|
130
|
+
RETURNING status
|
|
131
|
+
SQL
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# == Schema Information
|
|
4
|
+
#
|
|
5
|
+
# Table name: sidekiq_batch_jobs
|
|
6
|
+
#
|
|
7
|
+
# id :bigint not null, primary key
|
|
8
|
+
# args :jsonb not null
|
|
9
|
+
# error_class :string
|
|
10
|
+
# error_message :text
|
|
11
|
+
# jid :string not null
|
|
12
|
+
# status :integer default("pending"), not null
|
|
13
|
+
# worker_class :string not null
|
|
14
|
+
# created_at :datetime not null
|
|
15
|
+
# updated_at :datetime not null
|
|
16
|
+
# sidekiq_batch_id :bigint not null
|
|
17
|
+
#
|
|
18
|
+
class SidekiqBatchJob < ActiveRecord::Base
|
|
19
|
+
ERROR_MESSAGE_MAX = 4_000
|
|
20
|
+
|
|
21
|
+
enum :status, { pending: 0, complete: 1, failed: 2 }, suffix: :status
|
|
22
|
+
|
|
23
|
+
belongs_to :sidekiq_batch
|
|
24
|
+
|
|
25
|
+
validates :jid, presence: true, uniqueness: true
|
|
26
|
+
validates :worker_class, presence: true
|
|
27
|
+
|
|
28
|
+
# Idempotent: returns true if the row was transitioned from `pending`
|
|
29
|
+
# to `complete`; false if it was already terminal.
|
|
30
|
+
def mark_complete!
|
|
31
|
+
self.class.where(id: id, status: "pending").update_all(
|
|
32
|
+
status: self.class.statuses.fetch("complete"),
|
|
33
|
+
updated_at: Time.current
|
|
34
|
+
).positive?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Idempotent: returns true if the row was transitioned from `pending`
|
|
38
|
+
# to `failed`; false if it was already terminal.
|
|
39
|
+
def mark_failed!(error)
|
|
40
|
+
self.class.where(id: id, status: "pending").update_all(
|
|
41
|
+
status: self.class.statuses.fetch("failed"),
|
|
42
|
+
error_class: error.class.name,
|
|
43
|
+
error_message: error.message.to_s.truncate(ERROR_MESSAGE_MAX),
|
|
44
|
+
updated_at: Time.current
|
|
45
|
+
).positive?
|
|
46
|
+
end
|
|
47
|
+
end
|
data/docker-compose.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Spins up the services the test suite needs.
|
|
2
|
+
#
|
|
3
|
+
# Usage:
|
|
4
|
+
# docker compose up -d
|
|
5
|
+
# bundle exec rspec
|
|
6
|
+
# docker compose down
|
|
7
|
+
#
|
|
8
|
+
# Sidekiq tests run in fake mode (Sidekiq::Testing.fake!) so we don't need
|
|
9
|
+
# Redis here — only Postgres for ActiveRecord.
|
|
10
|
+
|
|
11
|
+
services:
|
|
12
|
+
postgres:
|
|
13
|
+
image: postgres:16-alpine
|
|
14
|
+
environment:
|
|
15
|
+
POSTGRES_USER: postgres
|
|
16
|
+
POSTGRES_PASSWORD: postgres
|
|
17
|
+
POSTGRES_DB: sidekiq_batch_jobs_test
|
|
18
|
+
ports:
|
|
19
|
+
- "5433:5432"
|
|
20
|
+
healthcheck:
|
|
21
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
22
|
+
interval: 2s
|
|
23
|
+
timeout: 5s
|
|
24
|
+
retries: 10
|
|
25
|
+
tmpfs:
|
|
26
|
+
- /var/lib/postgresql/data
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Sidekiq
|
|
7
|
+
module Batch
|
|
8
|
+
module Jobs
|
|
9
|
+
module Generators
|
|
10
|
+
class InstallGenerator < ::Rails::Generators::Base # rubocop:disable Style/Documentation
|
|
11
|
+
include ::Rails::Generators::Migration
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
def self.next_migration_number(dirname)
|
|
16
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def copy_migration
|
|
20
|
+
migration_template(
|
|
21
|
+
"create_sidekiq_batch_tables.rb.tt",
|
|
22
|
+
"db/migrate/create_sidekiq_batch_tables.rb"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class CreateSidekiqBatchTables < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :sidekiq_batches do |t|
|
|
4
|
+
t.string :description
|
|
5
|
+
t.integer :status, null: false, default: 0
|
|
6
|
+
t.integer :total_jobs, null: false, default: 0
|
|
7
|
+
t.jsonb :callbacks, null: false, default: {}
|
|
8
|
+
t.jsonb :context, null: false, default: {}
|
|
9
|
+
t.datetime :completed_at
|
|
10
|
+
t.datetime :callback_fired_at
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :sidekiq_batches, :status
|
|
16
|
+
add_index :sidekiq_batches, [:status, :callback_fired_at]
|
|
17
|
+
|
|
18
|
+
create_table :sidekiq_batch_jobs do |t|
|
|
19
|
+
t.references :sidekiq_batch, null: false, foreign_key: { on_delete: :cascade }
|
|
20
|
+
t.string :jid, null: false
|
|
21
|
+
t.string :worker_class, null: false
|
|
22
|
+
t.jsonb :args, null: false, default: []
|
|
23
|
+
t.integer :status, null: false, default: 0
|
|
24
|
+
t.string :error_class
|
|
25
|
+
t.text :error_message
|
|
26
|
+
|
|
27
|
+
t.timestamps
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
add_index :sidekiq_batch_jobs, :jid, unique: true
|
|
31
|
+
add_index :sidekiq_batch_jobs, [:sidekiq_batch_id, :status]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Sidekiq
|
|
6
|
+
module Batch
|
|
7
|
+
module Jobs
|
|
8
|
+
class Engine < ::Rails::Engine # rubocop:disable Style/Documentation
|
|
9
|
+
engine_name "sidekiq_batch_jobs"
|
|
10
|
+
|
|
11
|
+
config.after_initialize do
|
|
12
|
+
Sidekiq::Batch::Jobs.install! if Sidekiq::Batch::Jobs.auto_install
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sidekiq"
|
|
4
|
+
require_relative "jobs/version"
|
|
5
|
+
require_relative "jobs/engine" if defined?(Rails::Engine)
|
|
6
|
+
|
|
7
|
+
module Sidekiq
|
|
8
|
+
module Batch
|
|
9
|
+
module Jobs # rubocop:disable Style/Documentation
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :auto_install
|
|
14
|
+
end
|
|
15
|
+
self.auto_install = true
|
|
16
|
+
|
|
17
|
+
def self.disable_auto_install!
|
|
18
|
+
self.auto_install = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Idempotent — guarded by an installed flag so calling twice (auto-install
|
|
22
|
+
# plus an explicit call in specs, for example) doesn't double-register
|
|
23
|
+
# middleware or death handlers.
|
|
24
|
+
def self.install!
|
|
25
|
+
return if @installed
|
|
26
|
+
|
|
27
|
+
::Sidekiq.configure_client do |config|
|
|
28
|
+
config.client_middleware { |chain| chain.prepend ::SidekiqBatch::ClientMiddleware }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
::Sidekiq.configure_server do |config|
|
|
32
|
+
config.client_middleware { |chain| chain.prepend ::SidekiqBatch::ClientMiddleware }
|
|
33
|
+
config.server_middleware { |chain| chain.add ::SidekiqBatch::Middleware }
|
|
34
|
+
config.death_handlers << ->(job, ex) { ::SidekiqBatch::Middleware.handle_death(job, ex) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@installed = true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# For tests: forget that install! has run.
|
|
41
|
+
def self.reset_installed!
|
|
42
|
+
@installed = false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sidekiq-batch-jobs
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Douglas Greyling
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 7.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 7.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: railties
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 7.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 7.1.0
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: sidekiq
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '7.0'
|
|
47
|
+
- - "<"
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '9'
|
|
50
|
+
type: :runtime
|
|
51
|
+
prerelease: false
|
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '7.0'
|
|
57
|
+
- - "<"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '9'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: combustion
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '1.4'
|
|
67
|
+
type: :development
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - "~>"
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '1.4'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: concurrent-ruby
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - "~>"
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '1.2'
|
|
81
|
+
type: :development
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - "~>"
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '1.2'
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: database_cleaner-active_record
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - "~>"
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '2.2'
|
|
95
|
+
type: :development
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - "~>"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '2.2'
|
|
102
|
+
- !ruby/object:Gem::Dependency
|
|
103
|
+
name: factory_bot
|
|
104
|
+
requirement: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - "~>"
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '6.4'
|
|
109
|
+
type: :development
|
|
110
|
+
prerelease: false
|
|
111
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - "~>"
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '6.4'
|
|
116
|
+
- !ruby/object:Gem::Dependency
|
|
117
|
+
name: pg
|
|
118
|
+
requirement: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - "~>"
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: '1.5'
|
|
123
|
+
type: :development
|
|
124
|
+
prerelease: false
|
|
125
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
126
|
+
requirements:
|
|
127
|
+
- - "~>"
|
|
128
|
+
- !ruby/object:Gem::Version
|
|
129
|
+
version: '1.5'
|
|
130
|
+
- !ruby/object:Gem::Dependency
|
|
131
|
+
name: rspec-rails
|
|
132
|
+
requirement: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - "~>"
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '6.1'
|
|
137
|
+
type: :development
|
|
138
|
+
prerelease: false
|
|
139
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
140
|
+
requirements:
|
|
141
|
+
- - "~>"
|
|
142
|
+
- !ruby/object:Gem::Version
|
|
143
|
+
version: '6.1'
|
|
144
|
+
- !ruby/object:Gem::Dependency
|
|
145
|
+
name: rspec-sidekiq
|
|
146
|
+
requirement: !ruby/object:Gem::Requirement
|
|
147
|
+
requirements:
|
|
148
|
+
- - "~>"
|
|
149
|
+
- !ruby/object:Gem::Version
|
|
150
|
+
version: '5.0'
|
|
151
|
+
type: :development
|
|
152
|
+
prerelease: false
|
|
153
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
154
|
+
requirements:
|
|
155
|
+
- - "~>"
|
|
156
|
+
- !ruby/object:Gem::Version
|
|
157
|
+
version: '5.0'
|
|
158
|
+
- !ruby/object:Gem::Dependency
|
|
159
|
+
name: shoulda-matchers
|
|
160
|
+
requirement: !ruby/object:Gem::Requirement
|
|
161
|
+
requirements:
|
|
162
|
+
- - "~>"
|
|
163
|
+
- !ruby/object:Gem::Version
|
|
164
|
+
version: '6.0'
|
|
165
|
+
type: :development
|
|
166
|
+
prerelease: false
|
|
167
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
168
|
+
requirements:
|
|
169
|
+
- - "~>"
|
|
170
|
+
- !ruby/object:Gem::Version
|
|
171
|
+
version: '6.0'
|
|
172
|
+
description: |
|
|
173
|
+
A hand-rolled alternative to Sidekiq Pro batches. Track a group of Sidekiq
|
|
174
|
+
jobs as a batch in PostgreSQL, get atomic completion detection, and fire
|
|
175
|
+
a callback worker when the batch finishes (success or failure).
|
|
176
|
+
email:
|
|
177
|
+
- greyling.douglas@gmail.com
|
|
178
|
+
executables: []
|
|
179
|
+
extensions: []
|
|
180
|
+
extra_rdoc_files: []
|
|
181
|
+
files:
|
|
182
|
+
- README.md
|
|
183
|
+
- Rakefile
|
|
184
|
+
- app/models/sidekiq_batch.rb
|
|
185
|
+
- app/models/sidekiq_batch/batch_enrollment_context.rb
|
|
186
|
+
- app/models/sidekiq_batch/client_middleware.rb
|
|
187
|
+
- app/models/sidekiq_batch/middleware.rb
|
|
188
|
+
- app/models/sidekiq_batch_job.rb
|
|
189
|
+
- docker-compose.yml
|
|
190
|
+
- lib/generators/sidekiq/batch/jobs/install/install_generator.rb
|
|
191
|
+
- lib/generators/sidekiq/batch/jobs/install/templates/create_sidekiq_batch_tables.rb.tt
|
|
192
|
+
- lib/sidekiq/batch/jobs.rb
|
|
193
|
+
- lib/sidekiq/batch/jobs/engine.rb
|
|
194
|
+
- lib/sidekiq/batch/jobs/version.rb
|
|
195
|
+
- sig/sidekiq/batch/jobs.rbs
|
|
196
|
+
homepage: https://github.com/douglasgreyling/sidekiq-batch-jobs
|
|
197
|
+
licenses:
|
|
198
|
+
- MIT
|
|
199
|
+
metadata:
|
|
200
|
+
homepage_uri: https://github.com/douglasgreyling/sidekiq-batch-jobs
|
|
201
|
+
source_code_uri: https://github.com/douglasgreyling/sidekiq-batch-jobs
|
|
202
|
+
rubygems_mfa_required: 'true'
|
|
203
|
+
rdoc_options: []
|
|
204
|
+
require_paths:
|
|
205
|
+
- lib
|
|
206
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
207
|
+
requirements:
|
|
208
|
+
- - ">="
|
|
209
|
+
- !ruby/object:Gem::Version
|
|
210
|
+
version: 3.2.0
|
|
211
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
212
|
+
requirements:
|
|
213
|
+
- - ">="
|
|
214
|
+
- !ruby/object:Gem::Version
|
|
215
|
+
version: '0'
|
|
216
|
+
requirements: []
|
|
217
|
+
rubygems_version: 4.0.8
|
|
218
|
+
specification_version: 4
|
|
219
|
+
summary: Batch tracking and completion callbacks for Sidekiq, backed by ActiveRecord.
|
|
220
|
+
test_files: []
|