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
|
@@ -2,85 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
module DispatchPolicy
|
|
4
4
|
class Policy
|
|
5
|
-
|
|
5
|
+
DEFAULT_SHARD = "default"
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@context_builder = ->(_args) { {} }
|
|
11
|
-
@gates = []
|
|
12
|
-
@snapshots = {}
|
|
13
|
-
@dedupe_key_builder = nil
|
|
14
|
-
@round_robin_builder = nil
|
|
15
|
-
@round_robin_weight = :equal
|
|
16
|
-
@round_robin_window = 60
|
|
17
|
-
instance_eval(&block) if block
|
|
18
|
-
DispatchPolicy.registry[@name] = job_class
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def name(value = nil)
|
|
22
|
-
return @name if value.nil?
|
|
23
|
-
DispatchPolicy.registry.delete(@name)
|
|
24
|
-
@name = value.to_s
|
|
25
|
-
DispatchPolicy.registry[@name] = @job_class
|
|
26
|
-
end
|
|
7
|
+
attr_reader :name, :context_proc, :gates, :retry_strategy, :queue_name,
|
|
8
|
+
:admission_batch_size, :shard_by_proc, :partition_by_proc,
|
|
9
|
+
:fairness_half_life_seconds, :tick_admission_budget
|
|
27
10
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
11
|
+
def initialize(name:, context_proc:, gates:, retry_strategy: :restage,
|
|
12
|
+
queue_name: nil, admission_batch_size: nil, shard_by_proc: nil,
|
|
13
|
+
partition_by_proc: nil,
|
|
14
|
+
fairness_half_life_seconds: nil, tick_admission_budget: nil)
|
|
15
|
+
@name = name.to_s
|
|
16
|
+
@context_proc = context_proc
|
|
17
|
+
@gates = gates.freeze
|
|
18
|
+
@retry_strategy = retry_strategy
|
|
19
|
+
@queue_name = queue_name
|
|
20
|
+
@admission_batch_size = admission_batch_size
|
|
21
|
+
@shard_by_proc = shard_by_proc
|
|
22
|
+
@partition_by_proc = partition_by_proc
|
|
23
|
+
@fairness_half_life_seconds = fairness_half_life_seconds
|
|
24
|
+
@tick_admission_budget = tick_admission_budget
|
|
31
25
|
|
|
32
|
-
|
|
33
|
-
@context_builder
|
|
26
|
+
validate!
|
|
34
27
|
end
|
|
35
28
|
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
# Builds the Context the gates and shard_by will see at admission time.
|
|
30
|
+
# The user's context_proc receives the job's arguments. The gem then
|
|
31
|
+
# enriches the resulting hash with `queue_name` (the ActiveJob queue)
|
|
32
|
+
# so shard_by/partition_by can route by queue without the user having
|
|
33
|
+
# to thread it through their proc.
|
|
34
|
+
def build_context(arguments, queue_name: nil)
|
|
35
|
+
base = context_proc ? context_proc.call(arguments) : {}
|
|
36
|
+
base = (base || {}).to_h
|
|
37
|
+
base = base.merge(queue_name: queue_name) if queue_name
|
|
38
|
+
Context.wrap(base)
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
def
|
|
41
|
-
|
|
41
|
+
def partition_key_for(ctx)
|
|
42
|
+
partition_for(ctx)
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
# Policy-level partition scope. Both the staged_jobs row and the
|
|
46
|
+
# concurrency gate's inflight_jobs row use this single canonical
|
|
47
|
+
# value as their partition_key, so all gates enforce their state
|
|
48
|
+
# at exactly the same scope. Required: validate! raises if the
|
|
49
|
+
# policy is built without one.
|
|
50
|
+
def partition_for(ctx)
|
|
51
|
+
value = @partition_by_proc.call(ctx)
|
|
52
|
+
value.nil? ? "" : value.to_s
|
|
48
53
|
end
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@
|
|
55
|
-
end
|
|
55
|
+
# The shard a partition belongs to. Stable per (policy, partition_key)
|
|
56
|
+
# via first-writer-wins in Repository.upsert_partition!. If no shard_by
|
|
57
|
+
# is declared the partition lives on the "default" shard.
|
|
58
|
+
def shard_for(ctx)
|
|
59
|
+
return DEFAULT_SHARD unless @shard_by_proc
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
value = @shard_by_proc.call(ctx)
|
|
62
|
+
value.nil? ? DEFAULT_SHARD : value.to_s
|
|
59
63
|
end
|
|
60
64
|
|
|
61
|
-
def
|
|
62
|
-
|
|
65
|
+
def restage_retries?
|
|
66
|
+
retry_strategy == :restage
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
def
|
|
66
|
-
|
|
69
|
+
def bypass_retries?
|
|
70
|
+
retry_strategy == :bypass
|
|
67
71
|
end
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
return nil unless @round_robin_builder
|
|
71
|
-
key = @round_robin_builder.call(arguments)
|
|
72
|
-
key.nil? || key.to_s.empty? ? nil : key.to_s
|
|
73
|
-
end
|
|
73
|
+
private
|
|
74
74
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
def validate!
|
|
76
|
+
raise InvalidPolicy, "policy name required" if @name.empty?
|
|
77
|
+
raise InvalidPolicy, "partition_by required" unless @partition_by_proc
|
|
78
|
+
unless %i[restage bypass].include?(@retry_strategy)
|
|
79
|
+
raise InvalidPolicy, "retry_strategy must be :restage or :bypass"
|
|
78
80
|
end
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
# Note: gates are NOT required. A policy with no gates uses the
|
|
82
|
+
# admission_batch_size (or tick_admission_budget when set) as its
|
|
83
|
+
# only ceiling, with the in-tick fairness reorder distributing
|
|
84
|
+
# admissions across partitions. Useful for "balance N tenants
|
|
85
|
+
# fairly without rate-limiting any of them" workloads.
|
|
84
86
|
end
|
|
85
87
|
end
|
|
86
88
|
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
class PolicyDSL
|
|
5
|
+
GATE_TYPES = {
|
|
6
|
+
throttle: Gates::Throttle,
|
|
7
|
+
concurrency: Gates::Concurrency,
|
|
8
|
+
adaptive_concurrency: Gates::AdaptiveConcurrency
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def self.build(name, &block)
|
|
12
|
+
dsl = new(name)
|
|
13
|
+
dsl.instance_eval(&block) if block
|
|
14
|
+
dsl.to_policy
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(name)
|
|
18
|
+
@name = name
|
|
19
|
+
@context_proc = nil
|
|
20
|
+
@gates = []
|
|
21
|
+
@retry_strategy = :restage
|
|
22
|
+
@queue_name = nil
|
|
23
|
+
@admission_batch_size = nil
|
|
24
|
+
@shard_by_proc = nil
|
|
25
|
+
@partition_by_proc = nil
|
|
26
|
+
@fairness_half_life_seconds = nil
|
|
27
|
+
@tick_admission_budget = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def context(callable = nil, &block)
|
|
31
|
+
@context_proc = callable || block
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def gate(type, **options)
|
|
35
|
+
klass = GATE_TYPES[type] || raise(UnknownGate, "unknown gate type: #{type.inspect}")
|
|
36
|
+
@gates << klass.new(**options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def retry_strategy(strategy)
|
|
40
|
+
@retry_strategy = strategy
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def queue_name(name)
|
|
44
|
+
@queue_name = name.to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def admission_batch_size(size)
|
|
48
|
+
@admission_batch_size = Integer(size)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Per-policy override for the EWMA half-life used to weigh recent
|
|
52
|
+
# admissions when reordering claimed partitions inside the tick.
|
|
53
|
+
# Accepts a Numeric (seconds) or any object responding to `to_f`
|
|
54
|
+
# (so ActiveSupport durations like `30.seconds` work too).
|
|
55
|
+
# fairness half_life: 30.seconds
|
|
56
|
+
def fairness(half_life: nil)
|
|
57
|
+
@fairness_half_life_seconds = Float(half_life) if half_life
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Per-policy override for the global tick admission cap. nil
|
|
61
|
+
# (default) means use config.tick_admission_budget; if that's also
|
|
62
|
+
# nil, no global cap is enforced and per-partition admission_batch_size
|
|
63
|
+
# is the only ceiling.
|
|
64
|
+
def tick_admission_budget(value)
|
|
65
|
+
@tick_admission_budget = Integer(value)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Defines the partition scope. Required — every policy declares
|
|
69
|
+
# exactly one. Every gate in the policy uses this proc to compute
|
|
70
|
+
# the scope it enforces against (the staged_jobs row, the throttle
|
|
71
|
+
# bucket on that row, and the concurrency gate's inflight rows all
|
|
72
|
+
# share the same canonical key).
|
|
73
|
+
#
|
|
74
|
+
# dispatch_policy :endpoints do
|
|
75
|
+
# partition_by ->(ctx) { ctx[:endpoint_id] }
|
|
76
|
+
# gate :throttle, rate: 60, per: 60
|
|
77
|
+
# gate :concurrency, max: 5
|
|
78
|
+
# end
|
|
79
|
+
#
|
|
80
|
+
# If you need different scopes per gate (e.g. throttle by endpoint
|
|
81
|
+
# AND concurrency by account), use two policies and let one chain
|
|
82
|
+
# into the other.
|
|
83
|
+
def partition_by(callable = nil, &block)
|
|
84
|
+
@partition_by_proc = callable || block
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Routes a partition to a specific shard. The proc receives the
|
|
88
|
+
# enriched Context (which includes :queue_name from the job) and
|
|
89
|
+
# returns a string. Tick loops can be scoped per-shard so multiple
|
|
90
|
+
# workers can process a single policy in parallel.
|
|
91
|
+
#
|
|
92
|
+
# shard_by ->(ctx) { ctx[:queue_name] } # shard = job's queue
|
|
93
|
+
# shard_by ->(ctx) { "shard-#{ctx[:account_id].hash % 4}" } # explicit hash
|
|
94
|
+
#
|
|
95
|
+
# IMPORTANT: shard_by must be CONSISTENT with the gate's
|
|
96
|
+
# `partition_by` of any rate/concurrency budget you want to enforce
|
|
97
|
+
# globally. A throttle gate's bucket lives on the partition row, so
|
|
98
|
+
# if two staged_partitions sharing the same throttle key end up on
|
|
99
|
+
# different shards, each shard runs its own bucket and the effective
|
|
100
|
+
# rate becomes rate × N_shards.
|
|
101
|
+
def shard_by(callable = nil, &block)
|
|
102
|
+
@shard_by_proc = callable || block
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_policy
|
|
106
|
+
Policy.new(
|
|
107
|
+
name: @name,
|
|
108
|
+
context_proc: @context_proc,
|
|
109
|
+
gates: @gates,
|
|
110
|
+
retry_strategy: @retry_strategy,
|
|
111
|
+
queue_name: @queue_name,
|
|
112
|
+
admission_batch_size: @admission_batch_size,
|
|
113
|
+
shard_by_proc: @shard_by_proc,
|
|
114
|
+
partition_by_proc: @partition_by_proc,
|
|
115
|
+
fairness_half_life_seconds: @fairness_half_life_seconds,
|
|
116
|
+
tick_admission_budget: @tick_admission_budget
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module DispatchPolicy
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
initializer "dispatch_policy.active_job" do
|
|
8
|
+
ActiveSupport.on_load(:active_job) do
|
|
9
|
+
include DispatchPolicy::JobExtension
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
ActiveSupport.on_load(:active_job) do
|
|
13
|
+
if defined?(ActiveJob) && ActiveJob.respond_to?(:perform_all_later)
|
|
14
|
+
singleton = ActiveJob.singleton_class
|
|
15
|
+
unless singleton.include?(DispatchPolicy::JobExtension::BulkEnqueue)
|
|
16
|
+
singleton.prepend(DispatchPolicy::JobExtension::BulkEnqueue)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Hosts copy the gem's migration into their own db/migrate via
|
|
23
|
+
# `rails railties:install:migrations` (or hand-write a cutover
|
|
24
|
+
# migration like opstasks did). We deliberately do NOT auto-merge
|
|
25
|
+
# the gem's db/migrate into the host's lookup paths — that
|
|
26
|
+
# surfaces an `ActiveRecord::DuplicateMigrationNameError` for
|
|
27
|
+
# any host already carrying a migration named
|
|
28
|
+
# `CreateDispatchPolicyTables` (e.g. one copied from the
|
|
29
|
+
# upstream tick-hardening branch during a cutover).
|
|
30
|
+
|
|
31
|
+
config.after_initialize do
|
|
32
|
+
DispatchPolicy.warn_unsupported_adapter
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
class Registry
|
|
5
|
+
def initialize
|
|
6
|
+
@mutex = Mutex.new
|
|
7
|
+
@policies = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def register(policy, owner: nil)
|
|
11
|
+
@mutex.synchronize do
|
|
12
|
+
existing = @policies[policy.name]
|
|
13
|
+
if existing && existing[:owner] != owner
|
|
14
|
+
raise PolicyAlreadyRegistered, "policy #{policy.name.inspect} already registered for #{existing[:owner]}"
|
|
15
|
+
end
|
|
16
|
+
@policies[policy.name] = { policy: policy, owner: owner }
|
|
17
|
+
end
|
|
18
|
+
policy
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch(name)
|
|
22
|
+
entry = @policies[name.to_s]
|
|
23
|
+
entry && entry[:policy]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def [](name)
|
|
27
|
+
fetch(name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def names
|
|
31
|
+
@policies.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def each(&block)
|
|
35
|
+
@policies.values.map { |e| e[:policy] }.each(&block)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def size
|
|
39
|
+
@policies.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clear
|
|
43
|
+
@mutex.synchronize { @policies.clear }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|