dispatch_policy 0.2.0 → 0.3.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 +4 -4
- data/MIT-LICENSE +16 -17
- data/README.md +433 -388
- data/app/assets/stylesheets/dispatch_policy/application.css +157 -0
- data/app/controllers/dispatch_policy/application_controller.rb +45 -1
- data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
- data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
- data/app/controllers/dispatch_policy/policies_controller.rb +94 -267
- data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
- data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
- data/app/models/dispatch_policy/inflight_job.rb +12 -0
- data/app/models/dispatch_policy/partition.rb +21 -0
- data/app/models/dispatch_policy/staged_job.rb +4 -97
- data/app/models/dispatch_policy/tick_sample.rb +11 -0
- data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
- data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
- data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
- data/app/views/dispatch_policy/policies/index.html.erb +15 -37
- data/app/views/dispatch_policy/policies/show.html.erb +139 -223
- data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
- data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
- data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
- data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
- data/app/views/layouts/dispatch_policy/application.html.erb +95 -238
- data/config/routes.rb +18 -2
- data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
- data/lib/dispatch_policy/bypass.rb +23 -0
- data/lib/dispatch_policy/config.rb +85 -0
- data/lib/dispatch_policy/context.rb +50 -0
- data/lib/dispatch_policy/cursor_pagination.rb +121 -0
- data/lib/dispatch_policy/decision.rb +22 -0
- data/lib/dispatch_policy/engine.rb +4 -27
- data/lib/dispatch_policy/forwarder.rb +63 -0
- data/lib/dispatch_policy/gate.rb +10 -38
- data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
- data/lib/dispatch_policy/gates/concurrency.rb +45 -26
- data/lib/dispatch_policy/gates/throttle.rb +65 -41
- data/lib/dispatch_policy/inflight_tracker.rb +174 -0
- data/lib/dispatch_policy/job_extension.rb +155 -0
- data/lib/dispatch_policy/operator_hints.rb +126 -0
- data/lib/dispatch_policy/pipeline.rb +48 -0
- data/lib/dispatch_policy/policy.rb +61 -59
- data/lib/dispatch_policy/policy_dsl.rb +120 -0
- data/lib/dispatch_policy/railtie.rb +35 -0
- data/lib/dispatch_policy/registry.rb +46 -0
- data/lib/dispatch_policy/repository.rb +723 -0
- data/lib/dispatch_policy/serializer.rb +36 -0
- data/lib/dispatch_policy/tick.rb +260 -256
- data/lib/dispatch_policy/tick_loop.rb +59 -26
- data/lib/dispatch_policy/version.rb +1 -1
- data/lib/dispatch_policy.rb +71 -52
- data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
- data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
- data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
- data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
- metadata +101 -43
- data/CHANGELOG.md +0 -43
- data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
- data/app/models/dispatch_policy/partition_observation.rb +0 -76
- data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
- data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
- data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
- data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
- data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
- data/db/migrate/20260425000001_add_duration_to_partition_observations.rb +0 -8
- data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
- data/lib/dispatch_policy/dispatch_context.rb +0 -53
- data/lib/dispatch_policy/dispatchable.rb +0 -123
- data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
- data/lib/dispatch_policy/gates/global_cap.rb +0 -26
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module DispatchPolicy
|
|
4
|
-
class DispatchContext
|
|
5
|
-
def initialize(policy:, batch:)
|
|
6
|
-
@policy = policy
|
|
7
|
-
@cache = {}
|
|
8
|
-
@partitions = Hash.new { |h, k| h[k] = {} }
|
|
9
|
-
batch.each { |staged| resolve_for(staged) }
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def for(staged)
|
|
13
|
-
@cache[staged.id]
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def record_partitions(pairs, gate:)
|
|
17
|
-
pairs.each { |staged, partition_key| @partitions[staged.id][gate.to_sym] = partition_key.to_s }
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def partitions_for(staged)
|
|
21
|
-
@partitions[staged.id]
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def primary_partition_for(staged)
|
|
25
|
-
@partitions[staged.id].values.first
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def resolve_for(staged)
|
|
31
|
-
cached = staged.context
|
|
32
|
-
if cached.is_a?(Hash) && cached.present?
|
|
33
|
-
@cache[staged.id] = cached.symbolize_keys
|
|
34
|
-
return
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Fallback: recompute from the serialized args. Hit on rows staged
|
|
38
|
-
# before the context column existed, or when context_builder
|
|
39
|
-
# legitimately returned an empty hash.
|
|
40
|
-
raw = (staged.arguments || {})["arguments"] || []
|
|
41
|
-
args = begin
|
|
42
|
-
ActiveJob::Arguments.deserialize(raw)
|
|
43
|
-
rescue StandardError => e
|
|
44
|
-
Rails.logger&.warn(
|
|
45
|
-
"[DispatchPolicy] could not deserialize args for staged=#{staged.id} " \
|
|
46
|
-
"(policy=#{staged.policy_name}): #{e.class}: #{e.message}"
|
|
47
|
-
)
|
|
48
|
-
raw
|
|
49
|
-
end
|
|
50
|
-
@cache[staged.id] = @policy.context_builder.call(args)
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module DispatchPolicy
|
|
4
|
-
module Dispatchable
|
|
5
|
-
extend ActiveSupport::Concern
|
|
6
|
-
|
|
7
|
-
class_methods do
|
|
8
|
-
def dispatch_policy(&block)
|
|
9
|
-
@dispatch_policy = DispatchPolicy::Policy.new(self, &block)
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def dispatch_policy?
|
|
13
|
-
!@dispatch_policy.nil?
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def resolved_dispatch_policy
|
|
17
|
-
@dispatch_policy
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Walk up the ancestor chain so subclasses inherit the parent policy.
|
|
21
|
-
def inherited(subclass)
|
|
22
|
-
super
|
|
23
|
-
subclass.instance_variable_set(:@dispatch_policy, @dispatch_policy)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
included do
|
|
28
|
-
attr_accessor :_dispatch_partitions, :_dispatch_admitted_at
|
|
29
|
-
|
|
30
|
-
around_perform do |job, block|
|
|
31
|
-
# queue_lag = admitted_at → perform_start. Pure signal for "is the
|
|
32
|
-
# adapter queue building up?" (high = admitting too fast) vs "are
|
|
33
|
-
# workers idle?" (near zero = ready for more). Measured BEFORE
|
|
34
|
-
# block.call so perform duration doesn't pollute it.
|
|
35
|
-
admitted_at = job._dispatch_admitted_at
|
|
36
|
-
perform_start = Time.current
|
|
37
|
-
queue_lag_ms = admitted_at ? ((perform_start - admitted_at) * 1000).to_i : 0
|
|
38
|
-
|
|
39
|
-
succeeded = false
|
|
40
|
-
begin
|
|
41
|
-
block.call
|
|
42
|
-
succeeded = true
|
|
43
|
-
ensure
|
|
44
|
-
duration_ms = ((Time.current - perform_start) * 1000).to_i
|
|
45
|
-
policy_name = job.class.resolved_dispatch_policy&.name
|
|
46
|
-
|
|
47
|
-
if job._dispatch_partitions.present?
|
|
48
|
-
DispatchPolicy::Tick.release(
|
|
49
|
-
policy_name: policy_name,
|
|
50
|
-
partitions: job._dispatch_partitions
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
# Let adaptive gates update their AIMD state first; the
|
|
54
|
-
# generic observation below then captures the resulting
|
|
55
|
-
# current_max alongside lag + duration for the chart.
|
|
56
|
-
policy = job.class.resolved_dispatch_policy
|
|
57
|
-
job._dispatch_partitions.each do |gate_name, partition_key|
|
|
58
|
-
gate = policy&.gates&.find { |g| g.name == gate_name.to_sym }
|
|
59
|
-
next unless gate.is_a?(DispatchPolicy::Gates::AdaptiveConcurrency)
|
|
60
|
-
gate.record_observation(
|
|
61
|
-
partition_key: partition_key,
|
|
62
|
-
queue_lag_ms: queue_lag_ms,
|
|
63
|
-
succeeded: succeeded
|
|
64
|
-
)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Generic observation per unique partition. Every gate with
|
|
68
|
-
# partition_by (adaptive or not) gets a sparkline this way,
|
|
69
|
-
# plus :fair_time_share reads consumed_ms from here.
|
|
70
|
-
job._dispatch_partitions.values.uniq.each do |partition_key|
|
|
71
|
-
current_max = DispatchPolicy::AdaptiveConcurrencyStats.current_max_for(
|
|
72
|
-
policy_name: policy_name,
|
|
73
|
-
partition_key: partition_key
|
|
74
|
-
)
|
|
75
|
-
DispatchPolicy::PartitionObservation.observe!(
|
|
76
|
-
policy_name: policy_name,
|
|
77
|
-
partition_key: partition_key,
|
|
78
|
-
queue_lag_ms: queue_lag_ms,
|
|
79
|
-
duration_ms: duration_ms,
|
|
80
|
-
current_max: current_max
|
|
81
|
-
)
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
DispatchPolicy::StagedJob.mark_completed_by_active_job_id(job.job_id)
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def enqueue(options = {})
|
|
90
|
-
return super unless self.class.dispatch_policy?
|
|
91
|
-
if options[:_bypass_staging]
|
|
92
|
-
return super(options.except(:_bypass_staging))
|
|
93
|
-
end
|
|
94
|
-
return super unless DispatchPolicy.enabled?
|
|
95
|
-
|
|
96
|
-
# Mirror Active Job's scheduling option handling before staging.
|
|
97
|
-
self.scheduled_at = options[:wait].seconds.from_now if options[:wait]
|
|
98
|
-
self.scheduled_at = options[:wait_until] if options[:wait_until]
|
|
99
|
-
self.queue_name = self.class.queue_name_from_part(options[:queue]) if options[:queue]
|
|
100
|
-
self.priority = options[:priority].to_i if options[:priority]
|
|
101
|
-
|
|
102
|
-
DispatchPolicy::StagedJob.stage!(
|
|
103
|
-
job_instance: self,
|
|
104
|
-
policy: self.class.resolved_dispatch_policy
|
|
105
|
-
)
|
|
106
|
-
self
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def serialize
|
|
110
|
-
super.merge(
|
|
111
|
-
"_dispatch_partitions" => _dispatch_partitions || {},
|
|
112
|
-
"_dispatch_admitted_at" => _dispatch_admitted_at&.iso8601(6)
|
|
113
|
-
)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def deserialize(job_data)
|
|
117
|
-
super
|
|
118
|
-
self._dispatch_partitions = job_data["_dispatch_partitions"]
|
|
119
|
-
ts = job_data["_dispatch_admitted_at"]
|
|
120
|
-
self._dispatch_admitted_at = ts ? Time.iso8601(ts) : nil
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module DispatchPolicy
|
|
4
|
-
module Gates
|
|
5
|
-
class FairInterleave < Gate
|
|
6
|
-
def configure(**_); end
|
|
7
|
-
|
|
8
|
-
def filter(batch, context)
|
|
9
|
-
groups = batch.group_by do |staged|
|
|
10
|
-
if @partition_by
|
|
11
|
-
partition_key_for(context.for(staged))
|
|
12
|
-
else
|
|
13
|
-
context.primary_partition_for(staged) || staged.id
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
interleaved = []
|
|
17
|
-
loop do
|
|
18
|
-
taken = false
|
|
19
|
-
groups.each_value do |g|
|
|
20
|
-
next if g.empty?
|
|
21
|
-
interleaved << g.shift
|
|
22
|
-
taken = true
|
|
23
|
-
end
|
|
24
|
-
break unless taken
|
|
25
|
-
end
|
|
26
|
-
interleaved
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
Gate.register(:fair_interleave, FairInterleave)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module DispatchPolicy
|
|
4
|
-
module Gates
|
|
5
|
-
class GlobalCap < Gate
|
|
6
|
-
def configure(max:)
|
|
7
|
-
@max = max
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def tracks_inflight?
|
|
11
|
-
true
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def filter(batch, context)
|
|
15
|
-
limit = resolve(@max, nil).to_i
|
|
16
|
-
in_flight = PartitionInflightCount.total_for(policy_name: policy.name, gate_name: name)
|
|
17
|
-
capacity = [ limit - in_flight, 0 ].max
|
|
18
|
-
head = batch.first(capacity)
|
|
19
|
-
context.record_partitions(head.map { |s| [ s, "default" ] }, gate: name)
|
|
20
|
-
head
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
Gate.register(:global_cap, GlobalCap)
|
|
25
|
-
end
|
|
26
|
-
end
|