angry_batch 1.0.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: b40c1e274e731164e81dc6066fab25c89cb62a6bc9aac8bc2332a3387c061282
4
+ data.tar.gz: 22999450f01bb673c9729c48a1e52190a2f67771d9a1fe9ef2079f0b622f2780
5
+ SHA512:
6
+ metadata.gz: fe4deb92e43765b17ab14f2ae0edc013549dc0451c29c79a59661f8e0872fbb129d274d5c9f546e17ce40fdf9fb4c4a9b9aee0bf42a0bc7389efef3649ea75a7
7
+ data.tar.gz: 12b57e37c3fc89e7b912b2d6907ef89bd97777b3b4656eeb28529b4a8466b416e949b1ab40f5159ba15c59f0728e963dc55d1246bd3af5f28ca0b78b222fbc81
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,247 @@
1
+ plugins:
2
+ - rubocop-factory_bot
3
+ - rubocop-performance
4
+ - rubocop-rails
5
+ - rubocop-rake
6
+ - rubocop-rspec
7
+ - rubocop-rspec_rails
8
+
9
+ AllCops:
10
+ TargetRubyVersion: 3.2
11
+ NewCops: enable
12
+
13
+ Rails:
14
+ Enabled: true
15
+
16
+ Style/PercentLiteralDelimiters:
17
+ PreferredDelimiters:
18
+ "%i": "()"
19
+ "%w": "()"
20
+ "%r": "()"
21
+
22
+ Style/TrailingCommaInArrayLiteral:
23
+ EnforcedStyleForMultiline: comma
24
+
25
+ Style/TrailingCommaInHashLiteral:
26
+ EnforcedStyleForMultiline: comma
27
+
28
+ Style/TrailingCommaInArguments:
29
+ EnforcedStyleForMultiline: comma
30
+
31
+ Style/MultilineBlockChain:
32
+ Enabled: false
33
+
34
+ Style/Documentation:
35
+ Enabled: false
36
+
37
+ Style/ModuleFunction:
38
+ Enabled: false
39
+
40
+ Style/EachWithObject:
41
+ Enabled: false
42
+
43
+ Style/CollectionMethods:
44
+ Enabled: false
45
+
46
+ Style/ClassAndModuleChildren:
47
+ Enabled: false
48
+
49
+ Style/DoubleNegation:
50
+ Enabled: false
51
+
52
+ Style/HashEachMethods:
53
+ Enabled: false
54
+
55
+ Style/HashTransformKeys:
56
+ Enabled: false
57
+
58
+ Style/HashSyntax:
59
+ Enabled: false
60
+
61
+ Style/HashTransformValues:
62
+ Enabled: false
63
+
64
+ Style/NumberedParameters:
65
+ Enabled: false
66
+
67
+ Style/NumericLiterals:
68
+ Enabled: false
69
+
70
+ Style/ExponentialNotation:
71
+ Enabled: true
72
+
73
+ Style/RedundantFetchBlock:
74
+ Enabled: false
75
+
76
+ Style/CaseLikeIf:
77
+ Enabled: false
78
+
79
+ Style/GuardClause:
80
+ Enabled: false
81
+
82
+ Style/NumericPredicate:
83
+ Enabled: false
84
+
85
+ Style/StringConcatenation:
86
+ Enabled: false
87
+
88
+ Style/MinMaxComparison:
89
+ Enabled: false
90
+
91
+ Style/OpenStructUse:
92
+ Enabled: false
93
+
94
+ Style/MultilineTernaryOperator:
95
+ Enabled: false
96
+
97
+ Style/NestedTernaryOperator:
98
+ Enabled: false
99
+
100
+ Style/SafeNavigationChainLength:
101
+ Enabled: false
102
+
103
+ Style/SymbolProc:
104
+ Enabled: false
105
+
106
+ Layout/LineLength:
107
+ Enabled: false
108
+
109
+ Layout/SpaceAroundMethodCallOperator:
110
+ Enabled: true
111
+
112
+ Layout/FirstArrayElementIndentation:
113
+ Enabled: false
114
+
115
+ Layout/ArrayAlignment:
116
+ Enabled: false
117
+
118
+ Layout/IndentationWidth:
119
+ Enabled: false
120
+
121
+ Layout/ElseAlignment:
122
+ Enabled: false
123
+
124
+ Layout/EndAlignment:
125
+ Enabled: false
126
+
127
+ Metrics/ClassLength:
128
+ Enabled: false
129
+
130
+ Metrics/MethodLength:
131
+ Max: 20
132
+ Enabled: false
133
+
134
+ Metrics/AbcSize:
135
+ Max: 40
136
+ Enabled: false
137
+
138
+ Metrics/ModuleLength:
139
+ Enabled: false
140
+
141
+ Metrics/ParameterLists:
142
+ Enabled: false
143
+
144
+ Metrics/CyclomaticComplexity:
145
+ Enabled: false
146
+
147
+ Metrics/PerceivedComplexity:
148
+ Enabled: false
149
+
150
+ Metrics/BlockLength:
151
+ Enabled: false
152
+
153
+ Performance/Casecmp:
154
+ Enabled: false
155
+
156
+ Performance/TimesMap:
157
+ Enabled: false
158
+
159
+ Performance/ChainArrayAllocation:
160
+ Enabled: false
161
+
162
+ Lint/AmbiguousBlockAssociation:
163
+ Enabled: false
164
+
165
+ Lint/RaiseException:
166
+ Enabled: false
167
+
168
+ Lint/StructNewOverride:
169
+ Enabled: false
170
+
171
+ Lint/MissingSuper:
172
+ Enabled: false
173
+
174
+ Lint/UselessConstantScoping:
175
+ Enabled: false
176
+
177
+ Naming/InclusiveLanguage:
178
+ Enabled: false
179
+
180
+ Naming/PredicatePrefix:
181
+ Enabled: false
182
+
183
+ Naming/PredicateMethod:
184
+ Enabled: false
185
+
186
+ RSpec/MetadataStyle:
187
+ Enabled: false
188
+
189
+ RSpec/MultipleExpectations:
190
+ Enabled: false
191
+
192
+ RSpec/MatchArray:
193
+ Enabled: false
194
+
195
+ RSpec/ContextWording:
196
+ Enabled: false
197
+
198
+ RSpec/ExampleLength:
199
+ Enabled: false
200
+
201
+ RSpec/MessageChain:
202
+ Enabled: false
203
+
204
+ RSpec/VerifiedDoubles:
205
+ Enabled: false
206
+
207
+ RSpec/NestedGroups:
208
+ Enabled: false
209
+
210
+ RSpec/BeEq:
211
+ Enabled: false
212
+
213
+ RSpec/NoExpectationExample:
214
+ Enabled: false
215
+
216
+ RSpec/ClassCheck:
217
+ Enabled: false
218
+
219
+ RSpec/BeNil:
220
+ Enabled: false
221
+
222
+ RSpec/MultipleMemoizedHelpers:
223
+ Enabled: false
224
+
225
+ RSpec/IdenticalEqualityAssertion:
226
+ Enabled: false
227
+
228
+ Rails/ApplicationController:
229
+ Enabled: false
230
+
231
+ Rails/WhereRange:
232
+ Enabled: false
233
+
234
+ Rails/EnumSyntax:
235
+ Enabled: true
236
+
237
+ Rails/EnumHash:
238
+ Enabled: true
239
+
240
+ Rails/ApplicationRecord:
241
+ Enabled: false
242
+
243
+ Rails/ApplicationJob:
244
+ Enabled: false
245
+
246
+ Gemspec/DevelopmentDependencies:
247
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## Version 1.0.0 - 2025-07-20
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Radoslav Stankov
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # AngryBatch
2
+
3
+ ![Build Status](https://github.com/RStankov/AngryBatch/actions/workflows/main.yml/badge.svg)
4
+
5
+
6
+ **AngryBatch** is a lightweight batching utility for [ActiveJob](https://guides.rubyonrails.org/active_job_basics.html) that lets you group multiple jobs into a batch and trigger follow-up jobs when all jobs in the batch are done.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem 'angry_batch'
14
+ ```
15
+
16
+ And then execute:
17
+
18
+ ```
19
+ bundle
20
+ ```
21
+
22
+ Or install it yourself as:
23
+
24
+ ```
25
+ gem install angry_batch
26
+ ```
27
+
28
+ Then, from your Rails app directory, create the angry tables:
29
+
30
+
31
+ ```
32
+ rails generate angry_batch:install
33
+ rails db:migrate
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```ruby
39
+ # Step 1: Allow the job to be batchable
40
+ class SomeJob
41
+ include AngryBatch::Batchable
42
+ end
43
+
44
+ # Step 2: Create new batch queue
45
+ queue = AngryBatch.new(label: 'Debug label')
46
+
47
+ # Step 3: Add completion handler
48
+ # `on_complete` job will be called when all other queue jobs have completed
49
+ # (more than one handlers are supported)
50
+ queue.on_complete ToBeCalledWhenAllOtherJobsAreCompletedJob, argument
51
+
52
+ # Step 3.1: Add error handler
53
+ queue.on_failure HandleFailureJob, argument
54
+
55
+ # Step 4: Enqueue varios jobs
56
+ queue.enqueue SomeJob, argument1
57
+ queue.enqueue SomeJob, argument2
58
+ queue.enqueue SomeJob, argument3
59
+
60
+ # Step 5: Trigger all jobs in the queue
61
+ in the queue.perform_later
62
+ ```
63
+
64
+ ### Example
65
+
66
+ **Example 1**
67
+
68
+ ```ruby
69
+ # You have a building with tenants.
70
+ # Every month, you must generate rent payments for them and notify them accordingly.
71
+
72
+ def generate_rent(building, period)
73
+ queue = AngryBatch.new
74
+ queue.on_complete GenerateBudgetSnapshotJob, building
75
+ queue.on_complete NotifyBuildingOwnerJob, building
76
+
77
+ building.tenants.each do |tenant|
78
+ queue.enqueue GenerateTenantRentJob, tenant, period
79
+ end
80
+
81
+ queue.perform_later
82
+ end
83
+ ```
84
+
85
+ **Example 2**
86
+
87
+ ```ruby
88
+ # You have an account with many projects.
89
+ # For each project, you want to export its data individually.
90
+ # After all exports are done, you want to archive them into a zip file.
91
+
92
+ def export_account_information(account)
93
+ queue = AngryBatch.new(label: "Export Projects for #{account.id}")
94
+ queue.on_complete Export::ZipJob, account
95
+
96
+ account.projects.find_each do |project|
97
+ queue.enqueue Export::ProjectFilesJob, project
98
+ end
99
+
100
+ queue.perform_later
101
+ end
102
+ ```
103
+
104
+ ## Contributing
105
+
106
+ 1. Fork it
107
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
108
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
109
+ 4. Push to the branch (`git push origin my-new-feature`)
110
+ 5. Run the tests (`bundle exec rspec`)
111
+ 6. Create new Pull Request
112
+
113
+ ## Authors
114
+
115
+ * **Radoslav Stankov** - *creator* - [RStankov](https://github.com/RStankov)
116
+
117
+ ## License
118
+
119
+ **[MIT License](./LICENSE.txt)**
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(rubocop spec)
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: angry_batch_batches
6
+ #
7
+ # id :bigint(8) not null, primary key
8
+ # complete_handlers :jsonb not null
9
+ # failure_handlers :jsonb not null
10
+ # finished_at :datetime
11
+ # jobs_count :integer default(0), not null
12
+ # label :string
13
+ # state :string default("scheduling"), not null
14
+ # created_at :datetime not null
15
+ # updated_at :datetime not null
16
+ #
17
+ # Indexes
18
+ #
19
+ # index_angry_batch_batches_on_state (state)
20
+ #
21
+ class AngryBatch::Batch < ActiveRecord::Base
22
+ self.table_name = 'angry_batch_batches'
23
+
24
+ has_many :jobs, class_name: 'AngryBatch::Job', dependent: :delete_all
25
+
26
+ enum :state, {
27
+ scheduling: 'scheduling',
28
+ pending: 'pending',
29
+ completed: 'completed',
30
+ failed: 'failed',
31
+ }
32
+
33
+ class << self
34
+ def expired
35
+ completed = where('state = ? AND updated_at < ?', :completed, 2.days.ago)
36
+ failed = where('state = ? AND updated_at < ?', :failed, 4.weeks.ago)
37
+ pending = where('state = ? AND updated_at < ?', :pending, 4.weeks.ago)
38
+
39
+ completed.or(failed).or(pending)
40
+ end
41
+ end
42
+
43
+ def check_status_of_jobs
44
+ return unless pending?
45
+ return unless jobs_count == jobs.finished.count
46
+
47
+ self.finished_at = Time.current
48
+
49
+ if jobs.failed.none?
50
+ update! state: 'completed'
51
+
52
+ enqueue_handlers(complete_handlers)
53
+ else
54
+ update! state: 'failed'
55
+
56
+ enqueue_handlers(failure_handlers)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def enqueue_handlers(handlers)
63
+ handlers.each do |(job_class, job_arguments)|
64
+ if job_arguments.nil?
65
+ job_class.constantize.perform_later
66
+ else
67
+ job_class.constantize.perform_later(*ActiveJob::Arguments.deserialize(job_arguments))
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AngryBatch::Batchable
4
+ def self.included(base)
5
+ base.after_perform do |job|
6
+ AngryBatch::Handle.job_completed(job)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AngryBatch::Builder
4
+ def initialize(label: nil)
5
+ @batch = AngryBatch::Batch.new(
6
+ label: label,
7
+ state: 'scheduling',
8
+ complete_handlers: [],
9
+ failure_handlers: [],
10
+ )
11
+ @jobs = []
12
+ end
13
+
14
+ def performed?
15
+ @batch.persisted?
16
+ end
17
+
18
+ delegate :empty?, to: :@jobs
19
+
20
+ def on_complete(job_class, *, **)
21
+ raise AngryBatch::BatchArgumentError, 'Batch is already running' if performed?
22
+ raise AngryBatch::BatchArgumentError, "#{job_class} be a subclass of ActiveJob::Base" unless job_class.is_a?(Class) && job_class < ActiveJob::Base
23
+
24
+ @batch.complete_handlers << [job_class, job_class.new(*, **).serialize['arguments']]
25
+ end
26
+
27
+ def on_failure(job_class, *, **)
28
+ raise AngryBatch::BatchArgumentError, 'Batch is already running' if performed?
29
+ raise AngryBatch::BatchArgumentError, "#{job_class} be a subclass of ActiveJob::Base" unless job_class.is_a?(Class) && job_class < ActiveJob::Base
30
+
31
+ @batch.failure_handlers << [job_class, job_class.new(*, **).serialize['arguments']]
32
+ end
33
+
34
+ def enqueue(job_class, *, **)
35
+ raise AngryBatch::BatchArgumentError, 'Batch is already running' unless @batch.new_record?
36
+ raise AngryBatch::BatchArgumentError, "#{job_class} be a subclass of ActiveJob::Base" unless job_class.is_a?(Class) && job_class < ActiveJob::Base
37
+ raise AngryBatch::BatchArgumentError, "#{job_class} must include AngryBatch::Batchable" unless job_class.included_modules.include?(AngryBatch::Batchable)
38
+
39
+ @jobs << job_class.new(*, **)
40
+ end
41
+
42
+ def perform_later
43
+ raise AngryBatch::BatchArgumentError, 'Batch is empty' if empty?
44
+ raise AngryBatch::BatchArgumentError, 'Batch is already running' if performed?
45
+
46
+ @batch.save!
47
+
48
+ @jobs.each do |job|
49
+ @batch.jobs.create!(
50
+ active_job_idx: job.job_id,
51
+ active_job_class: job.class.name,
52
+ active_job_arguments: job.serialize['arguments'],
53
+ )
54
+
55
+ job.enqueue
56
+ end
57
+
58
+ @batch.update!(state: 'pending')
59
+ @batch.reload
60
+ @batch.check_status_of_jobs
61
+ end
62
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AngryBatch::CleanupCronJob < ActiveJob::Base
4
+ def perform
5
+ AngryBatch::Batch.expired.destroy_all
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AngryBatch::Handle
4
+ extend self
5
+
6
+ def job_completed(job)
7
+ record = AngryBatch::Job.find_by(active_job_idx: job.job_id)
8
+
9
+ return if record.blank?
10
+
11
+ record.with_lock do
12
+ record.update!(state: 'completed')
13
+ end
14
+
15
+ record.batch.check_status_of_jobs
16
+ end
17
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: angry_batch_jobs
6
+ #
7
+ # id :bigint(8) not null, primary key
8
+ # active_job_arguments :jsonb
9
+ # active_job_class :string not null
10
+ # active_job_idx :string not null
11
+ # error_message :string
12
+ # state :string default("pending"), not null
13
+ # created_at :datetime not null
14
+ # updated_at :datetime not null
15
+ # batch_id :bigint(8) not null
16
+ #
17
+ # Indexes
18
+ #
19
+ # index_angry_batch_jobs_on_active_job_idx (active_job_idx) UNIQUE
20
+ # index_angry_batch_jobs_on_batch_id_and_state (batch_id,state)
21
+ #
22
+ # Foreign Keys
23
+ #
24
+ # fk_rails_... (batch_id => angry_batch_batches.id)
25
+ #
26
+ class AngryBatch::Job < ActiveRecord::Base
27
+ self.table_name = 'angry_batch_jobs'
28
+
29
+ belongs_to :batch, class_name: 'AngryBatch::Batch', counter_cache: true
30
+
31
+ enum :state, {
32
+ pending: 'pending',
33
+ completed: 'completed',
34
+ failed: 'failed',
35
+ }
36
+
37
+ scope :finished, -> { where(state: %i(completed failed)) }
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AngryBatch
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'angry_batch/version'
4
+ require_relative 'angry_batch/job'
5
+ require_relative 'angry_batch/batch'
6
+ require_relative 'angry_batch/handle'
7
+ require_relative 'angry_batch/batchable'
8
+ require_relative 'angry_batch/builder'
9
+ require_relative 'angry_batch/cleanup_cron_job'
10
+
11
+ module AngryBatch
12
+ extend self
13
+
14
+ def new(**)
15
+ AngryBatch::Builder.new(**)
16
+ end
17
+
18
+ class BatchArgumentError < ArgumentError
19
+ end
20
+ end
21
+
22
+ if defined?(Rails)
23
+ require 'rails/engine'
24
+
25
+ module AngryBatch
26
+ class Engine < Rails::Engine
27
+ # Engine configuration
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AngryBatch
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ def self.next_migration_number(_dirname)
11
+ Time.now.utc.strftime('%Y%m%d%H%M%S')
12
+ end
13
+
14
+ def copy_migrations
15
+ migration_template 'create_angry_batch_tables.rb', 'db/migrate/create_angry_batch_tables.rb'
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAngryBatchTables < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :angry_batch_batches do |t|
6
+ begin
7
+ t.jsonb :complete_handlers, null: false, default: []
8
+ t.jsonb :failure_handlers, null: false, default: []
9
+ rescue NoMethodError
10
+ t.json :complete_handlers, null: false, default: []
11
+ t.json :failure_handlers, null: false, default: []
12
+ end
13
+
14
+ t.datetime :finished_at
15
+ t.integer :jobs_count, null: false, default: 0
16
+ t.string :label
17
+ t.string :state, null: false, default: 'scheduling'
18
+
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :angry_batch_batches, :state
23
+
24
+ create_table :angry_batch_jobs do |t|
25
+ t.references :batch, null: false, foreign_key: { to_table: :angry_batch_batches }, index: false
26
+ t.string :active_job_class, null: false
27
+ t.string :active_job_idx, null: false
28
+
29
+ begin
30
+ t.jsonb :active_job_arguments
31
+ rescue NoMethodError
32
+ t.json :active_job_arguments
33
+ end
34
+
35
+ t.string :error_message
36
+ t.string :state, null: false, default: 'pending'
37
+
38
+ t.timestamps
39
+ end
40
+
41
+ add_index :angry_batch_jobs, :active_job_idx, unique: true
42
+ add_index :angry_batch_jobs, %i(batch_id state)
43
+ end
44
+ end
@@ -0,0 +1,4 @@
1
+ module AngryBatch
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,290 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: angry_batch
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Radoslav Stankov
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-07-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: railties
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: factory_bot
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: factory_bot_rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec-rails
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rubocop-factory_bot
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rubocop-performance
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: rubocop-rails
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ - !ruby/object:Gem::Dependency
182
+ name: rubocop-rake
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ - !ruby/object:Gem::Dependency
196
+ name: rubocop-rspec
197
+ requirement: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - ">="
200
+ - !ruby/object:Gem::Version
201
+ version: '0'
202
+ type: :development
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ version: '0'
209
+ - !ruby/object:Gem::Dependency
210
+ name: rubocop-rspec_rails
211
+ requirement: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - ">="
214
+ - !ruby/object:Gem::Version
215
+ version: '0'
216
+ type: :development
217
+ prerelease: false
218
+ version_requirements: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
223
+ - !ruby/object:Gem::Dependency
224
+ name: sqlite3
225
+ requirement: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - ">="
228
+ - !ruby/object:Gem::Version
229
+ version: '0'
230
+ type: :development
231
+ prerelease: false
232
+ version_requirements: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - ">="
235
+ - !ruby/object:Gem::Version
236
+ version: '0'
237
+ description: AngryBatch allows you to group ActiveJobs into batches and define jobs
238
+ to run once all batch jobs are completed.
239
+ email:
240
+ - rstankov@gmail.com
241
+ executables: []
242
+ extensions: []
243
+ extra_rdoc_files: []
244
+ files:
245
+ - ".rspec"
246
+ - ".rubocop.yml"
247
+ - CHANGELOG.md
248
+ - CODE_OF_CONDUCT.md
249
+ - LICENSE.txt
250
+ - README.md
251
+ - Rakefile
252
+ - lib/angry_batch.rb
253
+ - lib/angry_batch/batch.rb
254
+ - lib/angry_batch/batchable.rb
255
+ - lib/angry_batch/builder.rb
256
+ - lib/angry_batch/cleanup_cron_job.rb
257
+ - lib/angry_batch/handle.rb
258
+ - lib/angry_batch/job.rb
259
+ - lib/angry_batch/version.rb
260
+ - lib/generators/angry_batch/install_generator.rb
261
+ - lib/generators/angry_batch/templates/create_angry_batch_tables.rb
262
+ - sig/angry_batch.rbs
263
+ homepage: https://github.com/RStankov/AngryBatch
264
+ licenses:
265
+ - MIT
266
+ metadata:
267
+ homepage_uri: https://github.com/RStankov/AngryBatch
268
+ source_code_uri: https://github.com/RStankov/AngryBatch
269
+ changelog_uri: https://github.com/RStankov/AngryBatch/blob/master/CHANGELOG.md
270
+ rubygems_mfa_required: 'true'
271
+ post_install_message:
272
+ rdoc_options: []
273
+ require_paths:
274
+ - lib
275
+ required_ruby_version: !ruby/object:Gem::Requirement
276
+ requirements:
277
+ - - ">="
278
+ - !ruby/object:Gem::Version
279
+ version: '3.2'
280
+ required_rubygems_version: !ruby/object:Gem::Requirement
281
+ requirements:
282
+ - - ">="
283
+ - !ruby/object:Gem::Version
284
+ version: '0'
285
+ requirements: []
286
+ rubygems_version: 3.5.11
287
+ signing_key:
288
+ specification_version: 4
289
+ summary: Lightweight ActiveJob batch processing with completion hooks
290
+ test_files: []