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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +16 -17
  3. data/README.md +433 -388
  4. data/app/assets/stylesheets/dispatch_policy/application.css +157 -0
  5. data/app/controllers/dispatch_policy/application_controller.rb +45 -1
  6. data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
  7. data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
  8. data/app/controllers/dispatch_policy/policies_controller.rb +94 -267
  9. data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
  10. data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
  11. data/app/models/dispatch_policy/inflight_job.rb +12 -0
  12. data/app/models/dispatch_policy/partition.rb +21 -0
  13. data/app/models/dispatch_policy/staged_job.rb +4 -97
  14. data/app/models/dispatch_policy/tick_sample.rb +11 -0
  15. data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
  16. data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
  17. data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
  18. data/app/views/dispatch_policy/policies/index.html.erb +15 -37
  19. data/app/views/dispatch_policy/policies/show.html.erb +139 -223
  20. data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
  21. data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
  22. data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
  23. data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
  24. data/app/views/layouts/dispatch_policy/application.html.erb +95 -238
  25. data/config/routes.rb +18 -2
  26. data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
  27. data/lib/dispatch_policy/bypass.rb +23 -0
  28. data/lib/dispatch_policy/config.rb +85 -0
  29. data/lib/dispatch_policy/context.rb +50 -0
  30. data/lib/dispatch_policy/cursor_pagination.rb +121 -0
  31. data/lib/dispatch_policy/decision.rb +22 -0
  32. data/lib/dispatch_policy/engine.rb +4 -27
  33. data/lib/dispatch_policy/forwarder.rb +63 -0
  34. data/lib/dispatch_policy/gate.rb +10 -38
  35. data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
  36. data/lib/dispatch_policy/gates/concurrency.rb +45 -26
  37. data/lib/dispatch_policy/gates/throttle.rb +65 -41
  38. data/lib/dispatch_policy/inflight_tracker.rb +174 -0
  39. data/lib/dispatch_policy/job_extension.rb +155 -0
  40. data/lib/dispatch_policy/operator_hints.rb +126 -0
  41. data/lib/dispatch_policy/pipeline.rb +48 -0
  42. data/lib/dispatch_policy/policy.rb +61 -59
  43. data/lib/dispatch_policy/policy_dsl.rb +120 -0
  44. data/lib/dispatch_policy/railtie.rb +35 -0
  45. data/lib/dispatch_policy/registry.rb +46 -0
  46. data/lib/dispatch_policy/repository.rb +723 -0
  47. data/lib/dispatch_policy/serializer.rb +36 -0
  48. data/lib/dispatch_policy/tick.rb +260 -256
  49. data/lib/dispatch_policy/tick_loop.rb +59 -26
  50. data/lib/dispatch_policy/version.rb +1 -1
  51. data/lib/dispatch_policy.rb +71 -52
  52. data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
  53. data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
  54. data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
  55. data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
  56. metadata +101 -43
  57. data/CHANGELOG.md +0 -43
  58. data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
  59. data/app/models/dispatch_policy/partition_observation.rb +0 -76
  60. data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
  61. data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
  62. data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
  63. data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
  64. data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
  65. data/db/migrate/20260425000001_add_duration_to_partition_observations.rb +0 -8
  66. data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
  67. data/lib/dispatch_policy/dispatch_context.rb +0 -53
  68. data/lib/dispatch_policy/dispatchable.rb +0 -123
  69. data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
  70. 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
- attr_reader :job_class, :gates, :snapshots, :dedupe_key_builder
5
+ DEFAULT_SHARD = "default"
6
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
- @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 context(builder)
29
- @context_builder = builder
30
- end
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
- def context_builder
33
- @context_builder
26
+ validate!
34
27
  end
35
28
 
36
- def snapshot(key, builder)
37
- @snapshots[key.to_sym] = builder
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 dedupe_key(builder)
41
- @dedupe_key_builder = builder
41
+ def partition_key_for(ctx)
42
+ partition_for(ctx)
42
43
  end
43
44
 
44
- def build_dedupe_key(arguments)
45
- return nil unless @dedupe_key_builder
46
- key = @dedupe_key_builder.call(arguments)
47
- key&.to_s
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
- def round_robin_by(builder, weight: :equal, window: 60)
51
- raise ArgumentError, "weight must be :equal or :time" unless %i[equal time].include?(weight)
52
- @round_robin_builder = builder
53
- @round_robin_weight = weight
54
- @round_robin_window = window
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
- def round_robin?
58
- !@round_robin_builder.nil?
61
+ value = @shard_by_proc.call(ctx)
62
+ value.nil? ? DEFAULT_SHARD : value.to_s
59
63
  end
60
64
 
61
- def round_robin_weight
62
- @round_robin_weight
65
+ def restage_retries?
66
+ retry_strategy == :restage
63
67
  end
64
68
 
65
- def round_robin_window
66
- @round_robin_window
69
+ def bypass_retries?
70
+ retry_strategy == :bypass
67
71
  end
68
72
 
69
- def build_round_robin_key(arguments)
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 gate(type, **opts)
76
- gate_class = DispatchPolicy::Gate.registry.fetch(type.to_sym) do
77
- raise ArgumentError, "Unknown gate: #{type}. Known: #{DispatchPolicy::Gate.registry.keys}"
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
- @gates << gate_class.new(policy: self, name: type.to_sym, **opts)
80
- end
81
-
82
- def build_snapshot(arguments)
83
- @snapshots.transform_values { |builder| builder.call(arguments) }
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