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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +435 -0
  5. data/app/controllers/dispatch_policy/application_controller.rb +9 -0
  6. data/app/controllers/dispatch_policy/policies_controller.rb +269 -0
  7. data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +89 -0
  8. data/app/models/dispatch_policy/application_record.rb +7 -0
  9. data/app/models/dispatch_policy/partition_inflight_count.rb +42 -0
  10. data/app/models/dispatch_policy/partition_observation.rb +49 -0
  11. data/app/models/dispatch_policy/staged_job.rb +105 -0
  12. data/app/models/dispatch_policy/throttle_bucket.rb +41 -0
  13. data/app/views/dispatch_policy/policies/index.html.erb +52 -0
  14. data/app/views/dispatch_policy/policies/show.html.erb +241 -0
  15. data/app/views/layouts/dispatch_policy/application.html.erb +266 -0
  16. data/config/routes.rb +6 -0
  17. data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +80 -0
  18. data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +22 -0
  19. data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +25 -0
  20. data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +32 -0
  21. data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +32 -0
  22. data/lib/dispatch_policy/dispatch_context.rb +53 -0
  23. data/lib/dispatch_policy/dispatchable.rb +120 -0
  24. data/lib/dispatch_policy/engine.rb +36 -0
  25. data/lib/dispatch_policy/gate.rb +49 -0
  26. data/lib/dispatch_policy/gates/adaptive_concurrency.rb +123 -0
  27. data/lib/dispatch_policy/gates/concurrency.rb +43 -0
  28. data/lib/dispatch_policy/gates/fair_interleave.rb +32 -0
  29. data/lib/dispatch_policy/gates/global_cap.rb +26 -0
  30. data/lib/dispatch_policy/gates/throttle.rb +52 -0
  31. data/lib/dispatch_policy/install_generator.rb +23 -0
  32. data/lib/dispatch_policy/policy.rb +73 -0
  33. data/lib/dispatch_policy/tick.rb +214 -0
  34. data/lib/dispatch_policy/tick_loop.rb +45 -0
  35. data/lib/dispatch_policy/version.rb +5 -0
  36. data/lib/dispatch_policy.rb +64 -0
  37. metadata +182 -0
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ class Tick
5
+ THROTTLE_ZERO_THRESHOLD = 0.001
6
+
7
+ # Single admission pass: fetch pending staged jobs per policy, evaluate
8
+ # gates, mark survivors as admitted, then enqueue them on the real
9
+ # backend outside the locking transaction.
10
+ def self.run(policy_name: nil)
11
+ return 0 unless DispatchPolicy.enabled?
12
+
13
+ pending_enqueue = []
14
+
15
+ StagedJob.transaction do
16
+ active_policies(policy_name).each do |pname|
17
+ policy = lookup_policy(pname)
18
+ next unless policy
19
+
20
+ batch = fetch_batch(policy)
21
+ next if batch.empty?
22
+
23
+ pending_enqueue.concat(run_policy(policy, batch))
24
+ end
25
+ end
26
+
27
+ admitted_count = 0
28
+ pending_enqueue.each do |staged, job|
29
+ begin
30
+ job.enqueue(_bypass_staging: true)
31
+ admitted_count += 1
32
+ rescue StandardError => e
33
+ Rails.logger&.error("[DispatchPolicy] enqueue failed staged=#{staged.id}: #{e.class}: #{e.message}")
34
+ revert_admission(staged)
35
+ end
36
+ end
37
+
38
+ admitted_count
39
+ end
40
+
41
+ def self.prune_idle_partitions
42
+ ttl = DispatchPolicy.config.partition_idle_ttl
43
+ return if ttl.nil? || ttl <= 0
44
+
45
+ cutoff = Time.current - ttl
46
+ PartitionInflightCount.where(in_flight: 0).where("updated_at < ?", cutoff).delete_all
47
+ ThrottleBucket.where("tokens <= ? AND refilled_at < ?", THROTTLE_ZERO_THRESHOLD, cutoff).delete_all
48
+ end
49
+
50
+ def self.prune_orphan_gate_rows
51
+ [ PartitionInflightCount, ThrottleBucket ].each do |model|
52
+ model.distinct.pluck(:policy_name, :gate_name).each do |policy_name, gate_name|
53
+ policy = lookup_policy(policy_name)
54
+ next if policy && policy.gates.any? { |g| g.name == gate_name.to_sym }
55
+
56
+ model.where(policy_name: policy_name, gate_name: gate_name).delete_all
57
+ end
58
+ end
59
+ end
60
+
61
+ def self.reap
62
+ StagedJob.expired_leases.find_each do |staged|
63
+ (staged.partitions || {}).each do |gate_name, partition_key|
64
+ policy = lookup_policy(staged.policy_name)
65
+ gate = policy&.gates&.find { |g| g.name == gate_name.to_sym }
66
+ next unless gate&.tracks_inflight?
67
+
68
+ PartitionInflightCount.decrement(
69
+ policy_name: staged.policy_name,
70
+ gate_name: gate_name.to_s,
71
+ partition_key: partition_key.to_s
72
+ )
73
+ end
74
+ staged.update!(lease_expires_at: nil, completed_at: Time.current)
75
+ end
76
+ end
77
+
78
+ def self.release(policy_name:, partitions:)
79
+ partitions.each do |gate_name, partition_key|
80
+ policy = lookup_policy(policy_name)
81
+ gate = policy&.gates&.find { |g| g.name == gate_name.to_sym }
82
+ next unless gate&.tracks_inflight?
83
+
84
+ PartitionInflightCount.decrement(
85
+ policy_name: policy_name,
86
+ gate_name: gate_name.to_s,
87
+ partition_key: partition_key.to_s
88
+ )
89
+ end
90
+ end
91
+
92
+ def self.active_policies(policy_name)
93
+ return [ policy_name ] if policy_name
94
+
95
+ StagedJob.pending
96
+ .where("not_before_at IS NULL OR not_before_at <= ?", Time.current)
97
+ .distinct
98
+ .pluck(:policy_name)
99
+ end
100
+
101
+ def self.fetch_batch(policy)
102
+ if policy.round_robin?
103
+ fetch_round_robin_batch(policy)
104
+ else
105
+ fetch_plain_batch(policy)
106
+ end
107
+ end
108
+
109
+ def self.fetch_plain_batch(policy)
110
+ StagedJob.pending
111
+ .where(policy_name: policy.name)
112
+ .where("not_before_at IS NULL OR not_before_at <= ?", Time.current)
113
+ .order(:priority, :staged_at)
114
+ .limit(DispatchPolicy.config.batch_size)
115
+ .lock("FOR UPDATE SKIP LOCKED")
116
+ .to_a
117
+ end
118
+
119
+ def self.fetch_round_robin_batch(policy)
120
+ quantum = DispatchPolicy.config.round_robin_quantum
121
+ batch_size = DispatchPolicy.config.batch_size
122
+ now = Time.current
123
+
124
+ sql = <<~SQL.squish
125
+ SELECT rows.*
126
+ FROM (
127
+ SELECT DISTINCT round_robin_key
128
+ FROM dispatch_policy_staged_jobs
129
+ WHERE policy_name = ?
130
+ AND admitted_at IS NULL
131
+ AND round_robin_key IS NOT NULL
132
+ AND (not_before_at IS NULL OR not_before_at <= ?)
133
+ ) AS keys
134
+ CROSS JOIN LATERAL (
135
+ SELECT *
136
+ FROM dispatch_policy_staged_jobs
137
+ WHERE policy_name = ?
138
+ AND admitted_at IS NULL
139
+ AND round_robin_key = keys.round_robin_key
140
+ AND (not_before_at IS NULL OR not_before_at <= ?)
141
+ ORDER BY priority, staged_at
142
+ LIMIT ?
143
+ FOR UPDATE SKIP LOCKED
144
+ ) AS rows
145
+ LIMIT ?
146
+ SQL
147
+
148
+ batch = StagedJob.find_by_sql([ sql, policy.name, now, policy.name, now, quantum, batch_size ])
149
+
150
+ remaining = batch_size - batch.size
151
+ return batch if remaining <= 0
152
+
153
+ top_up = StagedJob.pending
154
+ .where(policy_name: policy.name)
155
+ .where("not_before_at IS NULL OR not_before_at <= ?", now)
156
+ .where.not(id: batch.map(&:id))
157
+ .order(:priority, :staged_at)
158
+ .limit(remaining)
159
+ .lock("FOR UPDATE SKIP LOCKED")
160
+ .to_a
161
+
162
+ batch + top_up
163
+ end
164
+
165
+ def self.lookup_policy(policy_name)
166
+ job_class = DispatchPolicy.registry[policy_name] || autoload_job_for(policy_name)
167
+ return nil unless job_class
168
+ job_class.resolved_dispatch_policy
169
+ end
170
+
171
+ def self.autoload_job_for(policy_name)
172
+ const_name = policy_name.tr("-", "/").camelize
173
+ const_name.safe_constantize
174
+ DispatchPolicy.registry[policy_name]
175
+ end
176
+
177
+ def self.run_policy(policy, batch)
178
+ context = DispatchContext.new(policy: policy, batch: batch)
179
+ survivors = batch
180
+ policy.gates.each do |gate|
181
+ survivors = gate.filter(survivors, context)
182
+ end
183
+
184
+ survivors.map do |staged|
185
+ partitions = context.partitions_for(staged)
186
+
187
+ partitions.each do |gate_name, partition_key|
188
+ gate = policy.gates.find { |g| g.name == gate_name.to_sym }
189
+ next unless gate&.tracks_inflight?
190
+
191
+ PartitionInflightCount.increment(
192
+ policy_name: policy.name,
193
+ gate_name: gate_name.to_s,
194
+ partition_key: partition_key.to_s
195
+ )
196
+ end
197
+
198
+ job = staged.mark_admitted!(partitions: partitions)
199
+ [ staged, job ]
200
+ end
201
+ end
202
+
203
+ def self.revert_admission(staged)
204
+ partitions = staged.partitions || {}
205
+ release(policy_name: staged.policy_name, partitions: partitions)
206
+ staged.update_columns(
207
+ admitted_at: nil,
208
+ lease_expires_at: nil,
209
+ active_job_id: nil,
210
+ partitions: {}
211
+ )
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ # Shared driver for DispatchTickLoopJob and any foreground tick (e.g. a
5
+ # rake task). Loops Tick.reap + Tick.run with an interruptible sleep and
6
+ # bails when stop_when returns true.
7
+ class TickLoop
8
+ def self.run(policy_name: nil, sleep_for: nil, sleep_for_busy: nil, stop_when: -> { false })
9
+ idle_sleep = (sleep_for || DispatchPolicy.config.tick_sleep).to_f
10
+ busy_sleep = (sleep_for_busy || DispatchPolicy.config.tick_sleep_busy).to_f
11
+
12
+ loop do
13
+ break if stop_when.call
14
+
15
+ admitted = 0
16
+ begin
17
+ ActiveRecord::Base.uncached do
18
+ Tick.reap
19
+ admitted = Tick.run(policy_name: policy_name).to_i
20
+ end
21
+ rescue StandardError => e
22
+ Rails.logger&.error("[DispatchPolicy] tick error: #{e.class}: #{e.message}")
23
+ Rails.error.report(e, handled: true) if defined?(Rails) && Rails.respond_to?(:error)
24
+ end
25
+
26
+ break if stop_when.call
27
+
28
+ interruptible_sleep(admitted.positive? ? busy_sleep : idle_sleep, stop_when)
29
+ end
30
+ end
31
+
32
+ def self.interruptible_sleep(total, stop_when)
33
+ return unless total.positive?
34
+
35
+ remaining = total
36
+ step = 0.1
37
+ while remaining.positive?
38
+ break if stop_when.call
39
+ chunk = [ remaining, step ].min
40
+ sleep(chunk)
41
+ remaining -= chunk
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "active_record"
5
+
6
+ require "dispatch_policy/version"
7
+ require "dispatch_policy/engine" if defined?(Rails)
8
+
9
+ module DispatchPolicy
10
+ Config = Struct.new(
11
+ :enabled,
12
+ :lease_duration,
13
+ :batch_size,
14
+ :round_robin_quantum,
15
+ :tick_max_duration,
16
+ :tick_sleep,
17
+ :tick_sleep_busy,
18
+ :partition_idle_ttl,
19
+ keyword_init: true
20
+ )
21
+
22
+ def self.config
23
+ @config ||= Config.new(
24
+ enabled: true,
25
+ lease_duration: 15 * 60, # 15.minutes
26
+ batch_size: 500,
27
+ round_robin_quantum: 50,
28
+ tick_max_duration: 60, # 1.minute
29
+ tick_sleep: 1, # idle sleep
30
+ tick_sleep_busy: 0.05, # busy sleep
31
+ partition_idle_ttl: 30 * 60 # 30.minutes
32
+ )
33
+ end
34
+
35
+ def self.configure
36
+ yield config
37
+ end
38
+
39
+ def self.enabled?
40
+ config.enabled != false
41
+ end
42
+
43
+ # Registry: policy_name => job_class. Populated by Policy#initialize.
44
+ def self.registry
45
+ @registry ||= {}
46
+ end
47
+
48
+ def self.reset_registry!
49
+ @registry = {}
50
+ end
51
+ end
52
+
53
+ require "dispatch_policy/policy"
54
+ require "dispatch_policy/gate"
55
+ require "dispatch_policy/gates/concurrency"
56
+ require "dispatch_policy/gates/throttle"
57
+ require "dispatch_policy/gates/global_cap"
58
+ require "dispatch_policy/gates/fair_interleave"
59
+ require "dispatch_policy/gates/adaptive_concurrency"
60
+ require "dispatch_policy/dispatch_context"
61
+ require "dispatch_policy/dispatchable"
62
+ require "dispatch_policy/tick"
63
+ require "dispatch_policy/tick_loop"
64
+ require "dispatch_policy/active_job_perform_all_later_patch"
metadata ADDED
@@ -0,0 +1,182 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dispatch_policy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - José Galisteo
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activejob
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activerecord
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: pg
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: minitest
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: simplecov
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: sprockets-rails
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ description: DispatchPolicy stages ActiveJob enqueues into a policy table and admits
111
+ them through declared gates. Supports per-partition throttle/concurrency, dedupe,
112
+ round-robin fairness, and ships a minimal Rails engine to inspect pending/admitted
113
+ state.
114
+ email:
115
+ - ceritium@gmail.com
116
+ executables: []
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - CHANGELOG.md
121
+ - MIT-LICENSE
122
+ - README.md
123
+ - app/controllers/dispatch_policy/application_controller.rb
124
+ - app/controllers/dispatch_policy/policies_controller.rb
125
+ - app/models/dispatch_policy/adaptive_concurrency_stats.rb
126
+ - app/models/dispatch_policy/application_record.rb
127
+ - app/models/dispatch_policy/partition_inflight_count.rb
128
+ - app/models/dispatch_policy/partition_observation.rb
129
+ - app/models/dispatch_policy/staged_job.rb
130
+ - app/models/dispatch_policy/throttle_bucket.rb
131
+ - app/views/dispatch_policy/policies/index.html.erb
132
+ - app/views/dispatch_policy/policies/show.html.erb
133
+ - app/views/layouts/dispatch_policy/application.html.erb
134
+ - config/routes.rb
135
+ - db/migrate/20260424000001_create_dispatch_policy_tables.rb
136
+ - db/migrate/20260424000002_create_adaptive_concurrency_stats.rb
137
+ - db/migrate/20260424000003_create_adaptive_concurrency_samples.rb
138
+ - db/migrate/20260424000004_rename_samples_to_partition_observations.rb
139
+ - lib/dispatch_policy.rb
140
+ - lib/dispatch_policy/active_job_perform_all_later_patch.rb
141
+ - lib/dispatch_policy/dispatch_context.rb
142
+ - lib/dispatch_policy/dispatchable.rb
143
+ - lib/dispatch_policy/engine.rb
144
+ - lib/dispatch_policy/gate.rb
145
+ - lib/dispatch_policy/gates/adaptive_concurrency.rb
146
+ - lib/dispatch_policy/gates/concurrency.rb
147
+ - lib/dispatch_policy/gates/fair_interleave.rb
148
+ - lib/dispatch_policy/gates/global_cap.rb
149
+ - lib/dispatch_policy/gates/throttle.rb
150
+ - lib/dispatch_policy/install_generator.rb
151
+ - lib/dispatch_policy/policy.rb
152
+ - lib/dispatch_policy/tick.rb
153
+ - lib/dispatch_policy/tick_loop.rb
154
+ - lib/dispatch_policy/version.rb
155
+ homepage: https://github.com/ceritium/dispatch_policy
156
+ licenses:
157
+ - MIT
158
+ metadata:
159
+ homepage_uri: https://github.com/ceritium/dispatch_policy
160
+ source_code_uri: https://github.com/ceritium/dispatch_policy
161
+ bug_tracker_uri: https://github.com/ceritium/dispatch_policy/issues
162
+ changelog_uri: https://github.com/ceritium/dispatch_policy/blob/master/CHANGELOG.md
163
+ rubygems_mfa_required: 'true'
164
+ rdoc_options: []
165
+ require_paths:
166
+ - lib
167
+ required_ruby_version: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ version: '3.1'
172
+ required_rubygems_version: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - ">="
175
+ - !ruby/object:Gem::Version
176
+ version: '0'
177
+ requirements: []
178
+ rubygems_version: 4.0.3
179
+ specification_version: 4
180
+ summary: Per-partition admission control (throttle, concurrency, dedupe, fairness)
181
+ for ActiveJob.
182
+ test_files: []