dispatch_policy 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 +7 -0
- data/CHANGELOG.md +12 -0
- data/MIT-LICENSE +21 -0
- data/README.md +435 -0
- data/app/controllers/dispatch_policy/application_controller.rb +9 -0
- data/app/controllers/dispatch_policy/policies_controller.rb +269 -0
- data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +89 -0
- data/app/models/dispatch_policy/application_record.rb +7 -0
- data/app/models/dispatch_policy/partition_inflight_count.rb +42 -0
- data/app/models/dispatch_policy/partition_observation.rb +49 -0
- data/app/models/dispatch_policy/staged_job.rb +105 -0
- data/app/models/dispatch_policy/throttle_bucket.rb +41 -0
- data/app/views/dispatch_policy/policies/index.html.erb +52 -0
- data/app/views/dispatch_policy/policies/show.html.erb +241 -0
- data/app/views/layouts/dispatch_policy/application.html.erb +266 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +80 -0
- data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +22 -0
- data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +25 -0
- data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +32 -0
- data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +32 -0
- data/lib/dispatch_policy/dispatch_context.rb +53 -0
- data/lib/dispatch_policy/dispatchable.rb +120 -0
- data/lib/dispatch_policy/engine.rb +36 -0
- data/lib/dispatch_policy/gate.rb +49 -0
- data/lib/dispatch_policy/gates/adaptive_concurrency.rb +123 -0
- data/lib/dispatch_policy/gates/concurrency.rb +43 -0
- data/lib/dispatch_policy/gates/fair_interleave.rb +32 -0
- data/lib/dispatch_policy/gates/global_cap.rb +26 -0
- data/lib/dispatch_policy/gates/throttle.rb +52 -0
- data/lib/dispatch_policy/install_generator.rb +23 -0
- data/lib/dispatch_policy/policy.rb +73 -0
- data/lib/dispatch_policy/tick.rb +214 -0
- data/lib/dispatch_policy/tick_loop.rb +45 -0
- data/lib/dispatch_policy/version.rb +5 -0
- data/lib/dispatch_policy.rb +64 -0
- metadata +182 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
# Rails 7.1's ActiveJob.perform_all_later(*jobs) bypasses ActiveJob::Base#enqueue
|
|
5
|
+
# and calls queue_adapter.enqueue_all directly. Dispatchable hooks on #enqueue,
|
|
6
|
+
# so without this patch the batch path would skip staging.
|
|
7
|
+
module ActiveJobPerformAllLaterPatch
|
|
8
|
+
def perform_all_later(*jobs)
|
|
9
|
+
jobs.flatten!
|
|
10
|
+
|
|
11
|
+
staged, remaining = jobs.partition do |job|
|
|
12
|
+
klass = job.class
|
|
13
|
+
klass.respond_to?(:dispatch_policy?) &&
|
|
14
|
+
klass.dispatch_policy? &&
|
|
15
|
+
DispatchPolicy.enabled?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
staged_count = 0
|
|
19
|
+
staged.group_by(&:class).each do |klass, group|
|
|
20
|
+
staged_count += DispatchPolicy::StagedJob.stage_many!(
|
|
21
|
+
policy: klass.resolved_dispatch_policy,
|
|
22
|
+
jobs: group
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
remaining_count = remaining.empty? ? 0 : super(*remaining)
|
|
27
|
+
staged_count + remaining_count.to_i
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ActiveJob.singleton_class.prepend(DispatchPolicy::ActiveJobPerformAllLaterPatch)
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
policy_name = job.class.resolved_dispatch_policy&.name
|
|
45
|
+
|
|
46
|
+
if job._dispatch_partitions.present?
|
|
47
|
+
DispatchPolicy::Tick.release(
|
|
48
|
+
policy_name: policy_name,
|
|
49
|
+
partitions: job._dispatch_partitions
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Let adaptive gates update their AIMD state first; we pick up
|
|
53
|
+
# the resulting current_max in the generic observation below
|
|
54
|
+
# so the chart surfaces the cap alongside lag + completions.
|
|
55
|
+
policy = job.class.resolved_dispatch_policy
|
|
56
|
+
job._dispatch_partitions.each do |gate_name, partition_key|
|
|
57
|
+
gate = policy&.gates&.find { |g| g.name == gate_name.to_sym }
|
|
58
|
+
next unless gate.is_a?(DispatchPolicy::Gates::AdaptiveConcurrency)
|
|
59
|
+
gate.record_observation(
|
|
60
|
+
partition_key: partition_key,
|
|
61
|
+
queue_lag_ms: queue_lag_ms,
|
|
62
|
+
succeeded: succeeded
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Generic observation per unique partition. Every gate with
|
|
67
|
+
# partition_by (adaptive or not) gets a sparkline this way.
|
|
68
|
+
job._dispatch_partitions.values.uniq.each do |partition_key|
|
|
69
|
+
current_max = DispatchPolicy::AdaptiveConcurrencyStats.current_max_for(
|
|
70
|
+
policy_name: policy_name,
|
|
71
|
+
partition_key: partition_key
|
|
72
|
+
)
|
|
73
|
+
DispatchPolicy::PartitionObservation.observe!(
|
|
74
|
+
policy_name: policy_name,
|
|
75
|
+
partition_key: partition_key,
|
|
76
|
+
queue_lag_ms: queue_lag_ms,
|
|
77
|
+
current_max: current_max
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
DispatchPolicy::StagedJob.mark_completed_by_active_job_id(job.job_id)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def enqueue(options = {})
|
|
87
|
+
return super unless self.class.dispatch_policy?
|
|
88
|
+
if options[:_bypass_staging]
|
|
89
|
+
return super(options.except(:_bypass_staging))
|
|
90
|
+
end
|
|
91
|
+
return super unless DispatchPolicy.enabled?
|
|
92
|
+
|
|
93
|
+
# Mirror Active Job's scheduling option handling before staging.
|
|
94
|
+
self.scheduled_at = options[:wait].seconds.from_now if options[:wait]
|
|
95
|
+
self.scheduled_at = options[:wait_until] if options[:wait_until]
|
|
96
|
+
self.queue_name = self.class.queue_name_from_part(options[:queue]) if options[:queue]
|
|
97
|
+
self.priority = options[:priority].to_i if options[:priority]
|
|
98
|
+
|
|
99
|
+
DispatchPolicy::StagedJob.stage!(
|
|
100
|
+
job_instance: self,
|
|
101
|
+
policy: self.class.resolved_dispatch_policy
|
|
102
|
+
)
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def serialize
|
|
107
|
+
super.merge(
|
|
108
|
+
"_dispatch_partitions" => _dispatch_partitions || {},
|
|
109
|
+
"_dispatch_admitted_at" => _dispatch_admitted_at&.iso8601(6)
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def deserialize(job_data)
|
|
114
|
+
super
|
|
115
|
+
self._dispatch_partitions = job_data["_dispatch_partitions"]
|
|
116
|
+
ts = job_data["_dispatch_admitted_at"]
|
|
117
|
+
self._dispatch_admitted_at = ts ? Time.iso8601(ts) : nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module DispatchPolicy
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace DispatchPolicy
|
|
8
|
+
|
|
9
|
+
initializer "dispatch_policy.reference_gates" do
|
|
10
|
+
config.to_prepare do
|
|
11
|
+
# Reference the built-in gates so they register in Gate.registry.
|
|
12
|
+
DispatchPolicy::Gates::Concurrency
|
|
13
|
+
DispatchPolicy::Gates::Throttle
|
|
14
|
+
DispatchPolicy::Gates::GlobalCap
|
|
15
|
+
DispatchPolicy::Gates::FairInterleave
|
|
16
|
+
DispatchPolicy::Gates::AdaptiveConcurrency
|
|
17
|
+
|
|
18
|
+
DispatchPolicy::ActiveJobPerformAllLaterPatch
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
initializer "dispatch_policy.boot_prune", after: :load_config_initializers do
|
|
23
|
+
config.to_prepare do
|
|
24
|
+
begin
|
|
25
|
+
DispatchPolicy::Tick.prune_orphan_gate_rows
|
|
26
|
+
DispatchPolicy::Tick.prune_idle_partitions
|
|
27
|
+
DispatchPolicy::PartitionObservation.prune!
|
|
28
|
+
rescue ActiveRecord::NoDatabaseError,
|
|
29
|
+
ActiveRecord::StatementInvalid,
|
|
30
|
+
ActiveRecord::ConnectionNotEstablished
|
|
31
|
+
# DB not ready — skip silently.
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
class Gate
|
|
5
|
+
class << self
|
|
6
|
+
def registry
|
|
7
|
+
@registry ||= {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def register(name, klass)
|
|
11
|
+
registry[name.to_sym] = klass
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :policy, :partition_by, :name
|
|
16
|
+
|
|
17
|
+
def initialize(policy:, name:, partition_by: nil, **opts)
|
|
18
|
+
@policy = policy
|
|
19
|
+
@name = name
|
|
20
|
+
@partition_by = partition_by
|
|
21
|
+
configure(**opts)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configure(**_opts); end
|
|
25
|
+
|
|
26
|
+
# Resolve a partition key for a given context.
|
|
27
|
+
def partition_key_for(ctx)
|
|
28
|
+
return "default" if @partition_by.nil?
|
|
29
|
+
@partition_by.call(ctx).to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Subclasses must implement.
|
|
33
|
+
def filter(_batch, _context)
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether this gate keeps an in-flight count that must be released
|
|
38
|
+
# when the job finishes.
|
|
39
|
+
def tracks_inflight?
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
def resolve(value, ctx)
|
|
46
|
+
value.respond_to?(:call) ? value.call(ctx) : value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
module Gates
|
|
5
|
+
# Adaptive variant of :concurrency. The cap per partition (current_max)
|
|
6
|
+
# shrinks when the adapter queue backs up (recent queue_lag > target) or
|
|
7
|
+
# when performs fail; grows back when workers drain admissions quickly
|
|
8
|
+
# (queue_lag near zero). The signal is pure queue wait — admitted_at →
|
|
9
|
+
# perform_start — so it reflects "are we admitting too fast?" without
|
|
10
|
+
# getting polluted by how long the external work takes.
|
|
11
|
+
#
|
|
12
|
+
# AIMD loop on a per-partition stats row; the underlying in-flight
|
|
13
|
+
# counter is the same PartitionInflightCount used by :concurrency.
|
|
14
|
+
class AdaptiveConcurrency < Gate
|
|
15
|
+
# alpha is fast enough that a single spike is forgotten in ~3
|
|
16
|
+
# observations instead of ~15. slow_factor 0.95 halves the per-
|
|
17
|
+
# observation shrink magnitude so the cap no longer overshoots
|
|
18
|
+
# after a burst drains the adapter queue.
|
|
19
|
+
DEFAULT_EWMA_ALPHA = 0.5
|
|
20
|
+
DEFAULT_FAIL_FACTOR = 0.5
|
|
21
|
+
DEFAULT_SLOW_FACTOR = 0.95
|
|
22
|
+
|
|
23
|
+
# target_lag_ms accepts the legacy alias `target_latency` for
|
|
24
|
+
# backwards compatibility.
|
|
25
|
+
def configure(initial_max:,
|
|
26
|
+
target_lag_ms: nil,
|
|
27
|
+
target_latency: nil,
|
|
28
|
+
min: 1,
|
|
29
|
+
ewma_alpha: DEFAULT_EWMA_ALPHA,
|
|
30
|
+
failure_decrease_factor: DEFAULT_FAIL_FACTOR,
|
|
31
|
+
overload_decrease_factor: DEFAULT_SLOW_FACTOR)
|
|
32
|
+
@initial_max = initial_max
|
|
33
|
+
@min = min
|
|
34
|
+
@target_lag_ms = target_lag_ms || target_latency
|
|
35
|
+
@ewma_alpha = ewma_alpha
|
|
36
|
+
@fail_factor = failure_decrease_factor
|
|
37
|
+
@slow_factor = overload_decrease_factor
|
|
38
|
+
raise ArgumentError, "adaptive_concurrency requires target_lag_ms" if @target_lag_ms.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tracks_inflight?
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
attr_reader :initial_max, :min, :target_lag_ms,
|
|
46
|
+
:ewma_alpha, :fail_factor, :slow_factor
|
|
47
|
+
|
|
48
|
+
def filter(batch, context)
|
|
49
|
+
by_partition = batch.group_by { |staged| partition_key_for(context.for(staged)) }
|
|
50
|
+
|
|
51
|
+
# Seed any missing stats rows so the first admission has something
|
|
52
|
+
# to read. Cheap: one INSERT ... ON CONFLICT DO NOTHING per key.
|
|
53
|
+
by_partition.each_key do |key|
|
|
54
|
+
AdaptiveConcurrencyStats.seed!(
|
|
55
|
+
policy_name: policy.name,
|
|
56
|
+
gate_name: name,
|
|
57
|
+
partition_key: key,
|
|
58
|
+
initial_max: resolve(@initial_max, nil).to_i
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
stats = AdaptiveConcurrencyStats.fetch_many(
|
|
63
|
+
policy_name: policy.name,
|
|
64
|
+
gate_name: name,
|
|
65
|
+
partition_keys: by_partition.keys
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
in_flight = PartitionInflightCount.fetch_many(
|
|
69
|
+
policy_name: policy.name,
|
|
70
|
+
gate_name: name,
|
|
71
|
+
partition_keys: by_partition.keys
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
min_v = resolve(@min, nil).to_i
|
|
75
|
+
|
|
76
|
+
admitted = []
|
|
77
|
+
by_partition.each do |partition_key, jobs|
|
|
78
|
+
effective_max = stats.dig(partition_key, :current_max) || resolve(@initial_max, nil).to_i
|
|
79
|
+
effective_max = [ effective_max, min_v ].max
|
|
80
|
+
used = in_flight.fetch(partition_key, 0)
|
|
81
|
+
|
|
82
|
+
# Safety valve: if nothing is in-flight for this partition and
|
|
83
|
+
# there's pending, the adapter queue is (or is about to be)
|
|
84
|
+
# empty and workers will idle. Ensure we hand over at least
|
|
85
|
+
# initial_max so the stream never dries up on its own.
|
|
86
|
+
if used.zero? && jobs.any?
|
|
87
|
+
effective_max = [ effective_max, resolve(@initial_max, nil).to_i ].max
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
jobs.each do |staged|
|
|
91
|
+
break unless used < effective_max
|
|
92
|
+
admitted << [ staged, partition_key ]
|
|
93
|
+
used += 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
context.record_partitions(admitted, gate: name)
|
|
98
|
+
admitted.map(&:first)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Called by Dispatchable#around_perform for each adaptive gate that
|
|
102
|
+
# touched this job. Lives on the gate instance because configuration
|
|
103
|
+
# (alpha, target_latency, etc.) is per gate.
|
|
104
|
+
def record_observation(partition_key:, queue_lag_ms:, succeeded:)
|
|
105
|
+
AdaptiveConcurrencyStats.record_observation!(
|
|
106
|
+
policy_name: policy.name,
|
|
107
|
+
gate_name: name,
|
|
108
|
+
partition_key: partition_key.to_s,
|
|
109
|
+
queue_lag_ms: queue_lag_ms,
|
|
110
|
+
succeeded: succeeded,
|
|
111
|
+
alpha: @ewma_alpha,
|
|
112
|
+
min: resolve(@min, nil).to_i,
|
|
113
|
+
target_lag_ms: resolve(@target_lag_ms, nil).to_f,
|
|
114
|
+
fail_factor: @fail_factor,
|
|
115
|
+
slow_factor: @slow_factor,
|
|
116
|
+
initial_max: resolve(@initial_max, nil).to_i
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
Gate.register(:adaptive_concurrency, AdaptiveConcurrency)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
module Gates
|
|
5
|
+
class Concurrency < 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
|
+
by_partition = batch.group_by { |staged| partition_key_for(context.for(staged)) }
|
|
16
|
+
|
|
17
|
+
in_flight = PartitionInflightCount.fetch_many(
|
|
18
|
+
policy_name: policy.name,
|
|
19
|
+
gate_name: name,
|
|
20
|
+
partition_keys: by_partition.keys
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
admitted = []
|
|
24
|
+
by_partition.each do |partition_key, jobs|
|
|
25
|
+
jobs.each do |staged|
|
|
26
|
+
ctx = context.for(staged)
|
|
27
|
+
limit = resolve(@max, ctx).to_i
|
|
28
|
+
used = in_flight.fetch(partition_key, 0)
|
|
29
|
+
if used < limit
|
|
30
|
+
admitted << [ staged, partition_key ]
|
|
31
|
+
in_flight[partition_key] = used + 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
context.record_partitions(admitted, gate: name)
|
|
37
|
+
admitted.map(&:first)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Gate.register(:concurrency, Concurrency)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
module Gates
|
|
5
|
+
class Throttle < Gate
|
|
6
|
+
def configure(rate:, per:, burst: nil)
|
|
7
|
+
@rate = rate
|
|
8
|
+
@per = per
|
|
9
|
+
@burst = burst
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Consumed tokens refill over time, no release step.
|
|
13
|
+
def tracks_inflight?
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def filter(batch, context)
|
|
18
|
+
by_partition = batch.group_by { |staged| partition_key_for(context.for(staged)) }
|
|
19
|
+
|
|
20
|
+
admitted = []
|
|
21
|
+
by_partition.each do |partition_key, jobs|
|
|
22
|
+
sample_ctx = context.for(jobs.first)
|
|
23
|
+
rate = resolve(@rate, sample_ctx).to_f
|
|
24
|
+
per = @per.to_f
|
|
25
|
+
burst = (resolve(@burst, sample_ctx) || rate).to_f
|
|
26
|
+
|
|
27
|
+
bucket = ThrottleBucket.lock(
|
|
28
|
+
policy_name: policy.name,
|
|
29
|
+
gate_name: name,
|
|
30
|
+
partition_key: partition_key,
|
|
31
|
+
burst: burst
|
|
32
|
+
)
|
|
33
|
+
bucket.refill!(rate: rate, per: per, burst: burst)
|
|
34
|
+
|
|
35
|
+
jobs.each do |staged|
|
|
36
|
+
if bucket.consume(1)
|
|
37
|
+
admitted << [ staged, partition_key ]
|
|
38
|
+
else
|
|
39
|
+
break
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
bucket.save!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
context.record_partitions(admitted, gate: name)
|
|
46
|
+
admitted.map(&:first)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Gate.register(:throttle, Throttle)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module DispatchPolicy
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("../../db/migrate", __dir__)
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dirname)
|
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def copy_migration
|
|
18
|
+
migration_template "20260424000001_create_dispatch_policy_tables.rb",
|
|
19
|
+
"db/migrate/create_dispatch_policy_tables.rb"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
class Policy
|
|
5
|
+
attr_reader :job_class, :gates, :snapshots, :dedupe_key_builder
|
|
6
|
+
|
|
7
|
+
def initialize(job_class, &block)
|
|
8
|
+
@job_class = job_class
|
|
9
|
+
@name = job_class.name.underscore.tr("/", "-")
|
|
10
|
+
@context_builder = ->(_args) { {} }
|
|
11
|
+
@gates = []
|
|
12
|
+
@snapshots = {}
|
|
13
|
+
@dedupe_key_builder = nil
|
|
14
|
+
@round_robin_builder = nil
|
|
15
|
+
instance_eval(&block) if block
|
|
16
|
+
DispatchPolicy.registry[@name] = job_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def name(value = nil)
|
|
20
|
+
return @name if value.nil?
|
|
21
|
+
DispatchPolicy.registry.delete(@name)
|
|
22
|
+
@name = value.to_s
|
|
23
|
+
DispatchPolicy.registry[@name] = @job_class
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def context(builder)
|
|
27
|
+
@context_builder = builder
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def context_builder
|
|
31
|
+
@context_builder
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def snapshot(key, builder)
|
|
35
|
+
@snapshots[key.to_sym] = builder
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def dedupe_key(builder)
|
|
39
|
+
@dedupe_key_builder = builder
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_dedupe_key(arguments)
|
|
43
|
+
return nil unless @dedupe_key_builder
|
|
44
|
+
key = @dedupe_key_builder.call(arguments)
|
|
45
|
+
key&.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def round_robin_by(builder)
|
|
49
|
+
@round_robin_builder = builder
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def round_robin?
|
|
53
|
+
!@round_robin_builder.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_round_robin_key(arguments)
|
|
57
|
+
return nil unless @round_robin_builder
|
|
58
|
+
key = @round_robin_builder.call(arguments)
|
|
59
|
+
key.nil? || key.to_s.empty? ? nil : key.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def gate(type, **opts)
|
|
63
|
+
gate_class = DispatchPolicy::Gate.registry.fetch(type.to_sym) do
|
|
64
|
+
raise ArgumentError, "Unknown gate: #{type}. Known: #{DispatchPolicy::Gate.registry.keys}"
|
|
65
|
+
end
|
|
66
|
+
@gates << gate_class.new(policy: self, name: type.to_sym, **opts)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_snapshot(arguments)
|
|
70
|
+
@snapshots.transform_values { |builder| builder.call(arguments) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|