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
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DispatchPolicy
4
- class PartitionInflightCount < ApplicationRecord
5
- self.table_name = "dispatch_policy_partition_counts"
6
-
7
- def self.fetch_many(policy_name:, gate_name:, partition_keys:)
8
- return {} if partition_keys.empty?
9
-
10
- where(policy_name: policy_name, gate_name: gate_name.to_s, partition_key: partition_keys)
11
- .pluck(:partition_key, :in_flight).to_h
12
- .tap { |h| partition_keys.each { |k| h[k] ||= 0 } }
13
- end
14
-
15
- def self.total_for(policy_name:, gate_name:)
16
- where(policy_name: policy_name, gate_name: gate_name.to_s).sum(:in_flight)
17
- end
18
-
19
- def self.increment(policy_name:, gate_name:, partition_key:, by: 1)
20
- now = Time.current
21
- sql = <<~SQL.squish
22
- INSERT INTO #{quoted_table_name}
23
- (policy_name, gate_name, partition_key, in_flight, created_at, updated_at)
24
- VALUES (?, ?, ?, ?, ?, ?)
25
- ON CONFLICT (policy_name, gate_name, partition_key)
26
- DO UPDATE SET
27
- in_flight = #{quoted_table_name}.in_flight + EXCLUDED.in_flight,
28
- updated_at = EXCLUDED.updated_at
29
- SQL
30
- connection.exec_update(
31
- sanitize_sql_array([ sql, policy_name, gate_name.to_s, partition_key.to_s, by, now, now ])
32
- )
33
- end
34
-
35
- def self.decrement(policy_name:, gate_name:, partition_key:, by: 1)
36
- where(policy_name: policy_name, gate_name: gate_name.to_s, partition_key: partition_key.to_s)
37
- .update_all([
38
- "in_flight = GREATEST(in_flight - ?, 0), updated_at = ?", by, Time.current
39
- ])
40
- end
41
- end
42
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DispatchPolicy
4
- # Minute-bucketed observability per (policy, partition). Any gate with
5
- # partition_by gets an observation row here — adaptive, throttle,
6
- # concurrency, whatever — so the admin chart shows queue lag / throughput
7
- # for all partitioned policies, not just the adaptive ones.
8
- #
9
- # One row per (policy, partition, minute): total_lag_ms accumulates the
10
- # sum of queue_lag_ms observations in that minute, observation_count
11
- # increments, max_lag_ms tracks the worst spike. Average lag for the
12
- # bucket is derived on read as total / count.
13
- class PartitionObservation < ApplicationRecord
14
- self.table_name = "dispatch_policy_partition_observations"
15
-
16
- OBSERVATION_TTL = 2 * 60 * 60 # 2 hours
17
-
18
- def self.observe!(policy_name:, partition_key:, queue_lag_ms:, current_max: nil)
19
- return if partition_key.nil? || partition_key.to_s.empty?
20
-
21
- now = Time.current
22
- lag = queue_lag_ms.to_i
23
- sql = <<~SQL.squish
24
- INSERT INTO #{quoted_table_name}
25
- (policy_name, partition_key, minute_bucket,
26
- total_lag_ms, observation_count, max_lag_ms, current_max,
27
- created_at, updated_at)
28
- VALUES (?, ?, date_trunc('minute', ?::timestamp), ?, 1, ?, ?, ?, ?)
29
- ON CONFLICT (policy_name, partition_key, minute_bucket)
30
- DO UPDATE SET
31
- total_lag_ms = #{quoted_table_name}.total_lag_ms + EXCLUDED.total_lag_ms,
32
- observation_count = #{quoted_table_name}.observation_count + 1,
33
- max_lag_ms = GREATEST(#{quoted_table_name}.max_lag_ms, EXCLUDED.max_lag_ms),
34
- current_max = COALESCE(EXCLUDED.current_max, #{quoted_table_name}.current_max),
35
- updated_at = EXCLUDED.updated_at
36
- SQL
37
- connection.exec_update(
38
- sanitize_sql_array([
39
- sql, policy_name, partition_key.to_s, now,
40
- lag, lag, current_max, now, now
41
- ])
42
- )
43
- end
44
-
45
- def self.prune!
46
- where("minute_bucket < ?", Time.current - OBSERVATION_TTL).delete_all
47
- end
48
- end
49
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DispatchPolicy
4
- class ThrottleBucket < ApplicationRecord
5
- self.table_name = "dispatch_policy_throttle_buckets"
6
-
7
- def self.lock(policy_name:, gate_name:, partition_key:, burst:)
8
- now = Time.current
9
- seed_sql = <<~SQL.squish
10
- INSERT INTO #{quoted_table_name}
11
- (policy_name, gate_name, partition_key, tokens, refilled_at, created_at, updated_at)
12
- VALUES (?, ?, ?, ?, ?, ?, ?)
13
- ON CONFLICT (policy_name, gate_name, partition_key) DO NOTHING
14
- SQL
15
- connection.exec_update(
16
- sanitize_sql_array([
17
- seed_sql, policy_name, gate_name.to_s, partition_key.to_s,
18
- burst.to_f, now, now, now
19
- ])
20
- )
21
-
22
- where(policy_name: policy_name, gate_name: gate_name.to_s, partition_key: partition_key.to_s)
23
- .lock("FOR UPDATE")
24
- .first!
25
- end
26
-
27
- def refill!(rate:, per:, burst:)
28
- now = Time.current
29
- elapsed = (now - refilled_at).to_f
30
- new_tokens = tokens + (rate * elapsed / per)
31
- self.tokens = [ new_tokens, burst.to_f ].min
32
- self.refilled_at = now
33
- end
34
-
35
- def consume(n = 1)
36
- return false if tokens < n
37
- self.tokens -= n
38
- true
39
- end
40
- end
41
- end
@@ -1,80 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class CreateDispatchPolicyTables < ActiveRecord::Migration[7.1]
4
- def change
5
- create_table :dispatch_policy_staged_jobs do |t|
6
- t.string :job_class, null: false
7
- t.string :policy_name, null: false
8
- t.jsonb :arguments, null: false
9
- t.jsonb :snapshot, null: false, default: {}
10
- t.jsonb :context, null: false, default: {}
11
- t.integer :priority, null: false, default: 100
12
- t.datetime :not_before_at
13
- t.datetime :staged_at, null: false
14
- t.datetime :admitted_at
15
- t.datetime :completed_at
16
- t.datetime :lease_expires_at
17
- t.string :active_job_id
18
- t.string :dedupe_key
19
- t.string :round_robin_key
20
- t.jsonb :partitions, null: false, default: {}
21
-
22
- t.timestamps
23
- end
24
-
25
- add_index :dispatch_policy_staged_jobs,
26
- %i[policy_name priority staged_at],
27
- where: "admitted_at IS NULL",
28
- name: "idx_dp_staged_dispatch_order"
29
-
30
- add_index :dispatch_policy_staged_jobs,
31
- %i[policy_name dedupe_key],
32
- unique: true,
33
- where: "dedupe_key IS NOT NULL AND completed_at IS NULL",
34
- name: "idx_dp_staged_dedupe_active"
35
-
36
- add_index :dispatch_policy_staged_jobs,
37
- %i[lease_expires_at],
38
- where: "admitted_at IS NOT NULL",
39
- name: "idx_dp_staged_lease_expires"
40
-
41
- add_index :dispatch_policy_staged_jobs,
42
- %i[completed_at],
43
- where: "completed_at IS NOT NULL",
44
- name: "idx_dp_staged_completed_at"
45
-
46
- add_index :dispatch_policy_staged_jobs,
47
- %i[policy_name round_robin_key priority staged_at],
48
- where: "admitted_at IS NULL AND round_robin_key IS NOT NULL",
49
- name: "idx_dp_staged_round_robin"
50
-
51
- create_table :dispatch_policy_partition_counts do |t|
52
- t.string :policy_name, null: false
53
- t.string :gate_name, null: false
54
- t.string :partition_key, null: false, default: "default"
55
- t.integer :in_flight, null: false, default: 0
56
-
57
- t.timestamps
58
- end
59
-
60
- add_index :dispatch_policy_partition_counts,
61
- %i[policy_name gate_name partition_key],
62
- unique: true,
63
- name: "idx_dp_partition_counts_unique"
64
-
65
- create_table :dispatch_policy_throttle_buckets do |t|
66
- t.string :policy_name, null: false
67
- t.string :gate_name, null: false
68
- t.string :partition_key, null: false, default: "default"
69
- t.float :tokens, null: false
70
- t.datetime :refilled_at, null: false
71
-
72
- t.timestamps
73
- end
74
-
75
- add_index :dispatch_policy_throttle_buckets,
76
- %i[policy_name gate_name partition_key],
77
- unique: true,
78
- name: "idx_dp_throttle_buckets_unique"
79
- end
80
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class CreateAdaptiveConcurrencyStats < ActiveRecord::Migration[7.1]
4
- def change
5
- create_table :dispatch_policy_adaptive_concurrency_stats do |t|
6
- t.string :policy_name, null: false
7
- t.string :gate_name, null: false
8
- t.string :partition_key, null: false, default: "default"
9
- t.integer :current_max, null: false
10
- t.float :ewma_latency_ms, null: false, default: 0
11
- t.integer :sample_count, null: false, default: 0
12
- t.datetime :last_observed_at
13
-
14
- t.timestamps
15
- end
16
-
17
- add_index :dispatch_policy_adaptive_concurrency_stats,
18
- %i[policy_name gate_name partition_key],
19
- unique: true,
20
- name: "idx_dp_adaptive_concurrency_stats_unique"
21
- end
22
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class CreateAdaptiveConcurrencySamples < ActiveRecord::Migration[7.1]
4
- def change
5
- create_table :dispatch_policy_adaptive_concurrency_samples do |t|
6
- t.string :policy_name, null: false
7
- t.string :gate_name, null: false
8
- t.string :partition_key, null: false
9
- t.datetime :minute_bucket, null: false
10
- t.float :ewma_latency_ms, null: false, default: 0
11
- t.integer :current_max, null: false
12
-
13
- t.timestamps
14
- end
15
-
16
- add_index :dispatch_policy_adaptive_concurrency_samples,
17
- %i[policy_name gate_name partition_key minute_bucket],
18
- unique: true,
19
- name: "idx_dp_adaptive_concurrency_samples_unique"
20
-
21
- add_index :dispatch_policy_adaptive_concurrency_samples,
22
- :minute_bucket,
23
- name: "idx_dp_adaptive_concurrency_samples_time"
24
- end
25
- end
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class RenameSamplesToPartitionObservations < ActiveRecord::Migration[7.1]
4
- def up
5
- drop_table :dispatch_policy_adaptive_concurrency_samples, if_exists: true
6
-
7
- create_table :dispatch_policy_partition_observations do |t|
8
- t.string :policy_name, null: false
9
- t.string :partition_key, null: false
10
- t.datetime :minute_bucket, null: false
11
- t.bigint :total_lag_ms, null: false, default: 0
12
- t.integer :observation_count, null: false, default: 0
13
- t.integer :max_lag_ms, null: false, default: 0
14
- t.integer :current_max
15
-
16
- t.timestamps
17
- end
18
-
19
- add_index :dispatch_policy_partition_observations,
20
- %i[policy_name partition_key minute_bucket],
21
- unique: true,
22
- name: "idx_dp_partition_observations_unique"
23
-
24
- add_index :dispatch_policy_partition_observations,
25
- :minute_bucket,
26
- name: "idx_dp_partition_observations_time"
27
- end
28
-
29
- def down
30
- drop_table :dispatch_policy_partition_observations
31
- end
32
- end
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DispatchPolicy
4
- # Rails 7.1's ActiveJob.perform_all_later(*jobs) bypasses ActiveJob::Base#enqueue
5
- # and calls queue_adapter.enqueue_all directly. Dispatchable hooks on #enqueue,
6
- # so without this patch the batch path would skip staging.
7
- module ActiveJobPerformAllLaterPatch
8
- def perform_all_later(*jobs)
9
- jobs.flatten!
10
-
11
- staged, remaining = jobs.partition do |job|
12
- klass = job.class
13
- klass.respond_to?(:dispatch_policy?) &&
14
- klass.dispatch_policy? &&
15
- DispatchPolicy.enabled?
16
- end
17
-
18
- staged_count = 0
19
- staged.group_by(&:class).each do |klass, group|
20
- staged_count += DispatchPolicy::StagedJob.stage_many!(
21
- policy: klass.resolved_dispatch_policy,
22
- jobs: group
23
- )
24
- end
25
-
26
- remaining_count = remaining.empty? ? 0 : super(*remaining)
27
- staged_count + remaining_count.to_i
28
- end
29
- end
30
- end
31
-
32
- ActiveJob.singleton_class.prepend(DispatchPolicy::ActiveJobPerformAllLaterPatch)
@@ -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,120 +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
- policy_name = job.class.resolved_dispatch_policy&.name
45
-
46
- if job._dispatch_partitions.present?
47
- DispatchPolicy::Tick.release(
48
- policy_name: policy_name,
49
- partitions: job._dispatch_partitions
50
- )
51
-
52
- # Let adaptive gates update their AIMD state first; we pick up
53
- # the resulting current_max in the generic observation below
54
- # so the chart surfaces the cap alongside lag + completions.
55
- policy = job.class.resolved_dispatch_policy
56
- job._dispatch_partitions.each do |gate_name, partition_key|
57
- gate = policy&.gates&.find { |g| g.name == gate_name.to_sym }
58
- next unless gate.is_a?(DispatchPolicy::Gates::AdaptiveConcurrency)
59
- gate.record_observation(
60
- partition_key: partition_key,
61
- queue_lag_ms: queue_lag_ms,
62
- succeeded: succeeded
63
- )
64
- end
65
-
66
- # Generic observation per unique partition. Every gate with
67
- # partition_by (adaptive or not) gets a sparkline this way.
68
- job._dispatch_partitions.values.uniq.each do |partition_key|
69
- current_max = DispatchPolicy::AdaptiveConcurrencyStats.current_max_for(
70
- policy_name: policy_name,
71
- partition_key: partition_key
72
- )
73
- DispatchPolicy::PartitionObservation.observe!(
74
- policy_name: policy_name,
75
- partition_key: partition_key,
76
- queue_lag_ms: queue_lag_ms,
77
- current_max: current_max
78
- )
79
- end
80
- end
81
- DispatchPolicy::StagedJob.mark_completed_by_active_job_id(job.job_id)
82
- end
83
- end
84
- end
85
-
86
- def enqueue(options = {})
87
- return super unless self.class.dispatch_policy?
88
- if options[:_bypass_staging]
89
- return super(options.except(:_bypass_staging))
90
- end
91
- return super unless DispatchPolicy.enabled?
92
-
93
- # Mirror Active Job's scheduling option handling before staging.
94
- self.scheduled_at = options[:wait].seconds.from_now if options[:wait]
95
- self.scheduled_at = options[:wait_until] if options[:wait_until]
96
- self.queue_name = self.class.queue_name_from_part(options[:queue]) if options[:queue]
97
- self.priority = options[:priority].to_i if options[:priority]
98
-
99
- DispatchPolicy::StagedJob.stage!(
100
- job_instance: self,
101
- policy: self.class.resolved_dispatch_policy
102
- )
103
- self
104
- end
105
-
106
- def serialize
107
- super.merge(
108
- "_dispatch_partitions" => _dispatch_partitions || {},
109
- "_dispatch_admitted_at" => _dispatch_admitted_at&.iso8601(6)
110
- )
111
- end
112
-
113
- def deserialize(job_data)
114
- super
115
- self._dispatch_partitions = job_data["_dispatch_partitions"]
116
- ts = job_data["_dispatch_admitted_at"]
117
- self._dispatch_admitted_at = ts ? Time.iso8601(ts) : nil
118
- end
119
- end
120
- 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
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
- require "rails/generators/active_record"
5
-
6
- module DispatchPolicy
7
- module Generators
8
- class InstallGenerator < Rails::Generators::Base
9
- include Rails::Generators::Migration
10
-
11
- source_root File.expand_path("../../db/migrate", __dir__)
12
-
13
- def self.next_migration_number(dirname)
14
- ActiveRecord::Generators::Base.next_migration_number(dirname)
15
- end
16
-
17
- def copy_migration
18
- migration_template "20260424000001_create_dispatch_policy_tables.rb",
19
- "db/migrate/create_dispatch_policy_tables.rb"
20
- end
21
- end
22
- end
23
- end