@@ -14,6 +14,18 @@ class DiscardableNonOverlappingJob < NonOverlappingJob
1414 limits_concurrency key : -> ( job_result , **) { job_result } , on_conflict : :discard
1515 end
1616
17+ class BoundedBlockedJob < NonOverlappingJob
18+ limits_concurrency key : -> ( job_result , **) { job_result } , on_conflict : :block , max_blocked : 1
19+ end
20+
21+ class BoundedBlockedThrottledJob < NonOverlappingJob
22+ limits_concurrency to : 2 , key : -> ( job_result , **) { job_result } , on_conflict : :block , max_blocked : 2
23+ end
24+
25+ class DiscardableWithMaxBlockedJob < NonOverlappingJob
26+ limits_concurrency key : -> ( job_result , **) { job_result } , on_conflict : :discard , max_blocked : 5
27+ end
28+
1729 class DiscardableThrottledJob < NonOverlappingJob
1830 limits_concurrency to : 2 , key : -> ( job_result , **) { job_result } , on_conflict : :discard
1931 end
@@ -137,6 +149,90 @@ class DiscardableNonOverlappingGroupedJob2 < NonOverlappingJob
137149 end
138150 end
139151
152+ test "block jobs up to max_blocked, then discard further conflicts" do
153+ assert_ready do
154+ BoundedBlockedJob . perform_later ( @result , name : "ready" )
155+ end
156+
157+ assert_blocked do
158+ BoundedBlockedJob . perform_later ( @result , name : "blocked" )
159+ end
160+
161+ # Cap is hit: the next two jobs should be silently discarded
162+ assert_job_counts do
163+ BoundedBlockedJob . perform_later ( @result , name : "discarded-1" )
164+ BoundedBlockedJob . perform_later ( @result , name : "discarded-2" )
165+ end
166+ end
167+
168+ test "max_blocked cap reopens after the blocked job is released" do
169+ ready_active_job = BoundedBlockedJob . perform_later ( @result , name : "ready" )
170+ BoundedBlockedJob . perform_later ( @result , name : "blocked-1" )
171+ ready_job , blocked_job = SolidQueue ::Job . last ( 2 )
172+
173+ # Cap full: this enqueue is discarded
174+ assert_job_counts do
175+ BoundedBlockedJob . perform_later ( @result , name : "discarded" )
176+ end
177+
178+ # Discarding the ready job signals the semaphore and promotes the blocked one.
179+ # Net: ready_job destroyed (-1 Job), BlockedExecution -1, ReadyExecution unchanged
180+ # (one destroyed for ready_job, one created from promotion).
181+ assert_job_counts ( blocked : -1 ) do
182+ ready_job . discard
183+ end
184+ assert blocked_job . reload . ready?
185+
186+ # Cap is now empty; a fresh enqueue should block again, not be discarded
187+ assert_blocked do
188+ BoundedBlockedJob . perform_later ( @result , name : "newly-blocked" )
189+ end
190+ end
191+
192+ test "max_blocked enforces the cap independently per concurrency key" do
193+ another_result = JobResult . create! ( queue_name : "default" )
194+
195+ BoundedBlockedJob . perform_later ( @result , name : "ready-a" )
196+ BoundedBlockedJob . perform_later ( another_result , name : "ready-b" )
197+
198+ assert_job_counts ( blocked : 2 ) do
199+ BoundedBlockedJob . perform_later ( @result , name : "blocked-a" )
200+ BoundedBlockedJob . perform_later ( another_result , name : "blocked-b" )
201+ end
202+
203+ # Both keys are at cap; further enqueues for either key get discarded
204+ assert_job_counts do
205+ BoundedBlockedJob . perform_later ( @result , name : "discarded-a" )
206+ BoundedBlockedJob . perform_later ( another_result , name : "discarded-b" )
207+ end
208+ end
209+
210+ test "max_blocked respects concurrency_limit > 1" do
211+ # to: 2, max_blocked: 2 => up to 2 ready + up to 2 blocked
212+ assert_job_counts ( ready : 2 ) do
213+ BoundedBlockedThrottledJob . perform_later ( @result , name : "ready-1" )
214+ BoundedBlockedThrottledJob . perform_later ( @result , name : "ready-2" )
215+ end
216+
217+ assert_job_counts ( blocked : 2 ) do
218+ BoundedBlockedThrottledJob . perform_later ( @result , name : "blocked-1" )
219+ BoundedBlockedThrottledJob . perform_later ( @result , name : "blocked-2" )
220+ end
221+
222+ assert_job_counts do
223+ BoundedBlockedThrottledJob . perform_later ( @result , name : "discarded" )
224+ end
225+ end
226+
227+ test "max_blocked is a no-op when on_conflict is :discard" do
228+ # max_blocked is only consulted on the :block path; with :discard, every
229+ # conflict is destroyed regardless of how many are allegedly "allowed".
230+ assert_job_counts ( ready : 1 ) do
231+ DiscardableWithMaxBlockedJob . perform_later ( @result , name : "ready" )
232+ DiscardableWithMaxBlockedJob . perform_later ( @result , name : "would-have-blocked-if-cap-applied" )
233+ end
234+ end
235+
140236 test "block jobs in the same concurrency group when concurrency limits are reached" do
141237 assert_ready do
142238 active_job = NonOverlappingGroupedJob1 . perform_later ( @result , name : "A" )
0 commit comments