dispatch_policy 0.1.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 +449 -288
  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 -241
  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 +140 -216
  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 -37
  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 +62 -47
  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 +263 -172
  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 -46
  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 -12
  58. data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
  59. data/app/models/dispatch_policy/partition_observation.rb +0 -49
  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/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
  66. data/lib/dispatch_policy/dispatch_context.rb +0 -53
  67. data/lib/dispatch_policy/dispatchable.rb +0 -120
  68. data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
  69. data/lib/dispatch_policy/gates/global_cap.rb +0 -26
  70. data/lib/dispatch_policy/install_generator.rb +0 -23
@@ -2,72 +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
- instance_eval(&block) if block
16
- DispatchPolicy.registry[@name] = job_class
17
- 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
18
10
 
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
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
25
25
 
26
- def context(builder)
27
- @context_builder = builder
26
+ validate!
28
27
  end
29
28
 
30
- def context_builder
31
- @context_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)
32
39
  end
33
40
 
34
- def snapshot(key, builder)
35
- @snapshots[key.to_sym] = builder
41
+ def partition_key_for(ctx)
42
+ partition_for(ctx)
36
43
  end
37
44
 
38
- def dedupe_key(builder)
39
- @dedupe_key_builder = builder
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
40
53
  end
41
54
 
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
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
47
60
 
48
- def round_robin_by(builder)
49
- @round_robin_builder = builder
61
+ value = @shard_by_proc.call(ctx)
62
+ value.nil? ? DEFAULT_SHARD : value.to_s
50
63
  end
51
64
 
52
- def round_robin?
53
- !@round_robin_builder.nil?
65
+ def restage_retries?
66
+ retry_strategy == :restage
54
67
  end
55
68
 
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
69
+ def bypass_retries?
70
+ retry_strategy == :bypass
60
71
  end
61
72
 
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
73
+ private
68
74
 
69
- def build_snapshot(arguments)
70
- @snapshots.transform_values { |builder| builder.call(arguments) }
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"
80
+ end
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.
71
86
  end
72
87
  end
73
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