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
@@ -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