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 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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -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
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Batch
5
+ module Jobs
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ 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
@@ -0,0 +1,8 @@
1
+ module Sidekiq
2
+ module Batch
3
+ module Jobs
4
+ VERSION: String
5
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
6
+ end
7
+ end
8
+ 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: []