Skip to content

Commit 6a96cc1

Browse files
clauderobacarp
authored andcommitted
Add ResourceGate for resource-aware dequeue gating
Introduces ResourceGate, an abstract base class that controls whether queues should be dequeued based on external resource availability (GPU utilization, CPU load, network bandwidth, etc.). The base class handles TTL-based caching of check results; subclasses implement #check. Built-in gates: - OpenGate: always allows (default) - ThresholdGate: samples a metric via callback, blocks when >= threshold Gates are configured per-queue on QueueList via resource_gates hash. The QueueList#queues method filters out gated queues on every access, so dequeue adapters always see current resource state. Multiple queues can share a gate instance to represent a shared resource boundary. https://claude.ai/code/session_01PG9VitHQLVVWAJmMtFvQcA
1 parent e3edf47 commit 6a96cc1

14 files changed

Lines changed: 288 additions & 13 deletions

spec/helpers/mock_queue_list.cr

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
class MockQueueList < Mosquito::Runners::QueueList
2-
getter queues
32
setter state
43

4+
def discovered_queues : Array(Mosquito::Queue)
5+
@discovered_queues
6+
end
7+
58
def stop(wait_group : WaitGroup = WaitGroup.new(1)) : WaitGroup
69
self.state = Mosquito::Runnable::State::Stopping
710
spawn do

spec/mosquito/dequeue_adapters/shuffle_dequeue_adapter_spec.cr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ describe "Mosquito::ShuffleDequeueAdapter" do
77

88
def register(job_class : Mosquito::Job.class)
99
Mosquito::Base.register_job_mapping job_class.name.underscore, job_class
10-
queue_list.queues << job_class.queue
10+
queue_list.discovered_queues << job_class.queue
1111
end
1212

1313
it "is the default adapter" do
@@ -61,7 +61,7 @@ describe "Mosquito::ShuffleDequeueAdapter" do
6161
overseer.dequeue_adapter = spy_adapter
6262

6363
register QueuedTestJob
64-
queue_list.queues << Mosquito::Queue.new("extra_queue")
64+
queue_list.discovered_queues << Mosquito::Queue.new("extra_queue")
6565

6666
overseer.dequeue_job?
6767

spec/mosquito/dequeue_adapters/weighted_dequeue_adapter_spec.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe "Mosquito::WeightedDequeueAdapter" do
66

77
def register(job_class : Mosquito::Job.class)
88
Mosquito::Base.register_job_mapping job_class.name.underscore, job_class
9-
queue_list.queues << job_class.queue
9+
queue_list.discovered_queues << job_class.queue
1010
end
1111

1212
it "dequeues a job from a weighted queue" do
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
require "../spec_helper"
2+
3+
describe "Mosquito::OpenGate" do
4+
it "always allows" do
5+
gate = Mosquito::OpenGate.new
6+
assert gate.allow?
7+
end
8+
end
9+
10+
describe "Mosquito::ThresholdGate" do
11+
it "allows when metric is below threshold" do
12+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 0.seconds) { 50.0 }
13+
assert gate.allow?
14+
end
15+
16+
it "blocks when metric is at or above threshold" do
17+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 0.seconds) { 85.0 }
18+
refute gate.allow?
19+
end
20+
21+
it "blocks when metric equals threshold" do
22+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 0.seconds) { 80.0 }
23+
refute gate.allow?
24+
end
25+
end
26+
27+
describe "Mosquito::ResourceGate caching" do
28+
it "caches the check result within TTL" do
29+
call_count = 0
30+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 5.seconds) do
31+
call_count += 1
32+
50.0
33+
end
34+
35+
now = Time.utc
36+
Timecop.freeze(now) do
37+
gate.allow?
38+
gate.allow?
39+
gate.allow?
40+
assert_equal 1, call_count
41+
end
42+
end
43+
44+
it "re-checks after TTL expires" do
45+
call_count = 0
46+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 5.seconds) do
47+
call_count += 1
48+
50.0
49+
end
50+
51+
now = Time.utc
52+
Timecop.freeze(now) do
53+
gate.allow?
54+
assert_equal 1, call_count
55+
end
56+
57+
Timecop.freeze(now + 3.seconds) do
58+
gate.allow?
59+
assert_equal 1, call_count, "Should still be cached at 3s"
60+
end
61+
62+
Timecop.freeze(now + 6.seconds) do
63+
gate.allow?
64+
assert_equal 2, call_count, "Should re-check after 6s (past 5s TTL)"
65+
end
66+
end
67+
end

spec/mosquito/runners/coordinator_spec.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ describe "Mosquito::Runners::Coordinator" do
88
getter(enqueue_time) { Time.utc }
99

1010
def enqueue_job_run : JobRun
11-
queue_list.queues << queue
11+
queue_list.discovered_queues << queue
1212

1313
job_run = JobRun.new "blah"
1414

spec/mosquito/runners/executor_spec.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ describe "Mosquito::Runners::Executor" do
99

1010
def register(job_class : Mosquito::Job.class)
1111
Mosquito::Base.register_job_mapping job_class.name.underscore, job_class
12-
queue_list.queues << job_class.queue
12+
queue_list.discovered_queues << job_class.queue
1313
end
1414

1515
def run_job(job_class : Mosquito::Job.class)

spec/mosquito/runners/overseer_spec.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ describe "Mosquito::Runners::Overseer" do
88

99
def register(job_class : Mosquito::Job.class)
1010
Mosquito::Base.register_job_mapping job_class.name.underscore, job_class
11-
queue_list.queues << job_class.queue
11+
queue_list.discovered_queues << job_class.queue
1212
end
1313

1414
def run_job(job_class : Mosquito::Job.class)

spec/mosquito/runners/queue_list_spec.cr

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,84 @@ describe "Mosquito::Runners::QueueList" do
9595
end
9696
end
9797
end
98+
99+
describe "resource gate filtering" do
100+
it "excludes queues whose gate blocks" do
101+
clean_slate do
102+
enqueue_jobs
103+
queue_list.each_run
104+
105+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 0.seconds) { 90.0 }
106+
queue_list.resource_gates = {"passing_job" => gate.as(Mosquito::ResourceGate)}
107+
108+
refute_includes queue_list.queues.map(&.name), "passing_job"
109+
assert_includes queue_list.queues.map(&.name), "failing_job"
110+
assert_includes queue_list.queues.map(&.name), "io_queue"
111+
end
112+
end
113+
114+
it "includes queues whose gate allows" do
115+
clean_slate do
116+
enqueue_jobs
117+
queue_list.each_run
118+
119+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 0.seconds) { 50.0 }
120+
queue_list.resource_gates = {"passing_job" => gate.as(Mosquito::ResourceGate)}
121+
122+
assert_includes queue_list.queues.map(&.name), "passing_job"
123+
end
124+
end
125+
126+
it "ungated queues are always included" do
127+
clean_slate do
128+
enqueue_jobs
129+
queue_list.each_run
130+
131+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 0.seconds) { 90.0 }
132+
queue_list.resource_gates = {"passing_job" => gate.as(Mosquito::ResourceGate)}
133+
134+
assert_equal 2, queue_list.queues.size
135+
end
136+
end
137+
138+
it "multiple queues can share a gate" do
139+
clean_slate do
140+
enqueue_jobs
141+
queue_list.each_run
142+
143+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 0.seconds) { 90.0 }
144+
queue_list.resource_gates = {
145+
"passing_job" => gate.as(Mosquito::ResourceGate),
146+
"failing_job" => gate.as(Mosquito::ResourceGate),
147+
}
148+
149+
assert_equal ["io_queue"], queue_list.queues.map(&.name)
150+
end
151+
end
152+
153+
it "gate state is evaluated on each access" do
154+
clean_slate do
155+
enqueue_jobs
156+
queue_list.each_run
157+
158+
value = 90.0
159+
gate = Mosquito::ThresholdGate.new(threshold: 80.0, sample_ttl: 0.seconds) { value }
160+
queue_list.resource_gates = {"passing_job" => gate.as(Mosquito::ResourceGate)}
161+
162+
refute_includes queue_list.queues.map(&.name), "passing_job"
163+
164+
value = 50.0
165+
assert_includes queue_list.queues.map(&.name), "passing_job"
166+
end
167+
end
168+
169+
it "returns all queues when no gates are configured" do
170+
clean_slate do
171+
enqueue_jobs
172+
queue_list.each_run
173+
174+
assert_equal 3, queue_list.queues.size
175+
end
176+
end
177+
end
98178
end

src/mosquito/configuration.cr

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ module Mosquito
2020

2121
property dequeue_adapter : Mosquito::DequeueAdapter = Mosquito::ShuffleDequeueAdapter.new
2222

23+
# Maps queue names to resource gates. Queues whose gate returns
24+
# `false` from `#allow?` are excluded from dequeuing.
25+
property resource_gates : Hash(String, Mosquito::ResourceGate) = {} of String => Mosquito::ResourceGate
26+
2327
property publish_metrics : Bool = false
2428

2529
# How often a mosquito runner should emit a heartbeat metric.

src/mosquito/gates/open_gate.cr

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
require "../resource_gate"
2+
3+
module Mosquito
4+
# A gate that always allows dequeuing. This is the default when no
5+
# resource constraint is configured.
6+
class OpenGate < ResourceGate
7+
def initialize
8+
super(sample_ttl: 0.seconds)
9+
end
10+
11+
protected def check : Bool
12+
true
13+
end
14+
end
15+
end

0 commit comments

Comments
 (0)