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,32 @@
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)
@@ -0,0 +1,53 @@
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
@@ -0,0 +1,120 @@
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
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module DispatchPolicy
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace DispatchPolicy
8
+
9
+ initializer "dispatch_policy.reference_gates" do
10
+ config.to_prepare do
11
+ # Reference the built-in gates so they register in Gate.registry.
12
+ DispatchPolicy::Gates::Concurrency
13
+ DispatchPolicy::Gates::Throttle
14
+ DispatchPolicy::Gates::GlobalCap
15
+ DispatchPolicy::Gates::FairInterleave
16
+ DispatchPolicy::Gates::AdaptiveConcurrency
17
+
18
+ DispatchPolicy::ActiveJobPerformAllLaterPatch
19
+ end
20
+ end
21
+
22
+ initializer "dispatch_policy.boot_prune", after: :load_config_initializers do
23
+ config.to_prepare do
24
+ begin
25
+ DispatchPolicy::Tick.prune_orphan_gate_rows
26
+ DispatchPolicy::Tick.prune_idle_partitions
27
+ DispatchPolicy::PartitionObservation.prune!
28
+ rescue ActiveRecord::NoDatabaseError,
29
+ ActiveRecord::StatementInvalid,
30
+ ActiveRecord::ConnectionNotEstablished
31
+ # DB not ready — skip silently.
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ class Gate
5
+ class << self
6
+ def registry
7
+ @registry ||= {}
8
+ end
9
+
10
+ def register(name, klass)
11
+ registry[name.to_sym] = klass
12
+ end
13
+ end
14
+
15
+ attr_reader :policy, :partition_by, :name
16
+
17
+ def initialize(policy:, name:, partition_by: nil, **opts)
18
+ @policy = policy
19
+ @name = name
20
+ @partition_by = partition_by
21
+ configure(**opts)
22
+ end
23
+
24
+ def configure(**_opts); end
25
+
26
+ # Resolve a partition key for a given context.
27
+ def partition_key_for(ctx)
28
+ return "default" if @partition_by.nil?
29
+ @partition_by.call(ctx).to_s
30
+ end
31
+
32
+ # Subclasses must implement.
33
+ def filter(_batch, _context)
34
+ raise NotImplementedError
35
+ end
36
+
37
+ # Whether this gate keeps an in-flight count that must be released
38
+ # when the job finishes.
39
+ def tracks_inflight?
40
+ false
41
+ end
42
+
43
+ protected
44
+
45
+ def resolve(value, ctx)
46
+ value.respond_to?(:call) ? value.call(ctx) : value
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ module Gates
5
+ # Adaptive variant of :concurrency. The cap per partition (current_max)
6
+ # shrinks when the adapter queue backs up (recent queue_lag > target) or
7
+ # when performs fail; grows back when workers drain admissions quickly
8
+ # (queue_lag near zero). The signal is pure queue wait — admitted_at →
9
+ # perform_start — so it reflects "are we admitting too fast?" without
10
+ # getting polluted by how long the external work takes.
11
+ #
12
+ # AIMD loop on a per-partition stats row; the underlying in-flight
13
+ # counter is the same PartitionInflightCount used by :concurrency.
14
+ class AdaptiveConcurrency < Gate
15
+ # alpha is fast enough that a single spike is forgotten in ~3
16
+ # observations instead of ~15. slow_factor 0.95 halves the per-
17
+ # observation shrink magnitude so the cap no longer overshoots
18
+ # after a burst drains the adapter queue.
19
+ DEFAULT_EWMA_ALPHA = 0.5
20
+ DEFAULT_FAIL_FACTOR = 0.5
21
+ DEFAULT_SLOW_FACTOR = 0.95
22
+
23
+ # target_lag_ms accepts the legacy alias `target_latency` for
24
+ # backwards compatibility.
25
+ def configure(initial_max:,
26
+ target_lag_ms: nil,
27
+ target_latency: nil,
28
+ min: 1,
29
+ ewma_alpha: DEFAULT_EWMA_ALPHA,
30
+ failure_decrease_factor: DEFAULT_FAIL_FACTOR,
31
+ overload_decrease_factor: DEFAULT_SLOW_FACTOR)
32
+ @initial_max = initial_max
33
+ @min = min
34
+ @target_lag_ms = target_lag_ms || target_latency
35
+ @ewma_alpha = ewma_alpha
36
+ @fail_factor = failure_decrease_factor
37
+ @slow_factor = overload_decrease_factor
38
+ raise ArgumentError, "adaptive_concurrency requires target_lag_ms" if @target_lag_ms.nil?
39
+ end
40
+
41
+ def tracks_inflight?
42
+ true
43
+ end
44
+
45
+ attr_reader :initial_max, :min, :target_lag_ms,
46
+ :ewma_alpha, :fail_factor, :slow_factor
47
+
48
+ def filter(batch, context)
49
+ by_partition = batch.group_by { |staged| partition_key_for(context.for(staged)) }
50
+
51
+ # Seed any missing stats rows so the first admission has something
52
+ # to read. Cheap: one INSERT ... ON CONFLICT DO NOTHING per key.
53
+ by_partition.each_key do |key|
54
+ AdaptiveConcurrencyStats.seed!(
55
+ policy_name: policy.name,
56
+ gate_name: name,
57
+ partition_key: key,
58
+ initial_max: resolve(@initial_max, nil).to_i
59
+ )
60
+ end
61
+
62
+ stats = AdaptiveConcurrencyStats.fetch_many(
63
+ policy_name: policy.name,
64
+ gate_name: name,
65
+ partition_keys: by_partition.keys
66
+ )
67
+
68
+ in_flight = PartitionInflightCount.fetch_many(
69
+ policy_name: policy.name,
70
+ gate_name: name,
71
+ partition_keys: by_partition.keys
72
+ )
73
+
74
+ min_v = resolve(@min, nil).to_i
75
+
76
+ admitted = []
77
+ by_partition.each do |partition_key, jobs|
78
+ effective_max = stats.dig(partition_key, :current_max) || resolve(@initial_max, nil).to_i
79
+ effective_max = [ effective_max, min_v ].max
80
+ used = in_flight.fetch(partition_key, 0)
81
+
82
+ # Safety valve: if nothing is in-flight for this partition and
83
+ # there's pending, the adapter queue is (or is about to be)
84
+ # empty and workers will idle. Ensure we hand over at least
85
+ # initial_max so the stream never dries up on its own.
86
+ if used.zero? && jobs.any?
87
+ effective_max = [ effective_max, resolve(@initial_max, nil).to_i ].max
88
+ end
89
+
90
+ jobs.each do |staged|
91
+ break unless used < effective_max
92
+ admitted << [ staged, partition_key ]
93
+ used += 1
94
+ end
95
+ end
96
+
97
+ context.record_partitions(admitted, gate: name)
98
+ admitted.map(&:first)
99
+ end
100
+
101
+ # Called by Dispatchable#around_perform for each adaptive gate that
102
+ # touched this job. Lives on the gate instance because configuration
103
+ # (alpha, target_latency, etc.) is per gate.
104
+ def record_observation(partition_key:, queue_lag_ms:, succeeded:)
105
+ AdaptiveConcurrencyStats.record_observation!(
106
+ policy_name: policy.name,
107
+ gate_name: name,
108
+ partition_key: partition_key.to_s,
109
+ queue_lag_ms: queue_lag_ms,
110
+ succeeded: succeeded,
111
+ alpha: @ewma_alpha,
112
+ min: resolve(@min, nil).to_i,
113
+ target_lag_ms: resolve(@target_lag_ms, nil).to_f,
114
+ fail_factor: @fail_factor,
115
+ slow_factor: @slow_factor,
116
+ initial_max: resolve(@initial_max, nil).to_i
117
+ )
118
+ end
119
+ end
120
+
121
+ Gate.register(:adaptive_concurrency, AdaptiveConcurrency)
122
+ end
123
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ module Gates
5
+ class Concurrency < 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
+ by_partition = batch.group_by { |staged| partition_key_for(context.for(staged)) }
16
+
17
+ in_flight = PartitionInflightCount.fetch_many(
18
+ policy_name: policy.name,
19
+ gate_name: name,
20
+ partition_keys: by_partition.keys
21
+ )
22
+
23
+ admitted = []
24
+ by_partition.each do |partition_key, jobs|
25
+ jobs.each do |staged|
26
+ ctx = context.for(staged)
27
+ limit = resolve(@max, ctx).to_i
28
+ used = in_flight.fetch(partition_key, 0)
29
+ if used < limit
30
+ admitted << [ staged, partition_key ]
31
+ in_flight[partition_key] = used + 1
32
+ end
33
+ end
34
+ end
35
+
36
+ context.record_partitions(admitted, gate: name)
37
+ admitted.map(&:first)
38
+ end
39
+ end
40
+
41
+ Gate.register(:concurrency, Concurrency)
42
+ end
43
+ end
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ module Gates
5
+ class Throttle < Gate
6
+ def configure(rate:, per:, burst: nil)
7
+ @rate = rate
8
+ @per = per
9
+ @burst = burst
10
+ end
11
+
12
+ # Consumed tokens refill over time, no release step.
13
+ def tracks_inflight?
14
+ false
15
+ end
16
+
17
+ def filter(batch, context)
18
+ by_partition = batch.group_by { |staged| partition_key_for(context.for(staged)) }
19
+
20
+ admitted = []
21
+ by_partition.each do |partition_key, jobs|
22
+ sample_ctx = context.for(jobs.first)
23
+ rate = resolve(@rate, sample_ctx).to_f
24
+ per = @per.to_f
25
+ burst = (resolve(@burst, sample_ctx) || rate).to_f
26
+
27
+ bucket = ThrottleBucket.lock(
28
+ policy_name: policy.name,
29
+ gate_name: name,
30
+ partition_key: partition_key,
31
+ burst: burst
32
+ )
33
+ bucket.refill!(rate: rate, per: per, burst: burst)
34
+
35
+ jobs.each do |staged|
36
+ if bucket.consume(1)
37
+ admitted << [ staged, partition_key ]
38
+ else
39
+ break
40
+ end
41
+ end
42
+ bucket.save!
43
+ end
44
+
45
+ context.record_partitions(admitted, gate: name)
46
+ admitted.map(&:first)
47
+ end
48
+ end
49
+
50
+ Gate.register(:throttle, Throttle)
51
+ end
52
+ end
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ class Policy
5
+ attr_reader :job_class, :gates, :snapshots, :dedupe_key_builder
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
18
+
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
25
+
26
+ def context(builder)
27
+ @context_builder = builder
28
+ end
29
+
30
+ def context_builder
31
+ @context_builder
32
+ end
33
+
34
+ def snapshot(key, builder)
35
+ @snapshots[key.to_sym] = builder
36
+ end
37
+
38
+ def dedupe_key(builder)
39
+ @dedupe_key_builder = builder
40
+ end
41
+
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
47
+
48
+ def round_robin_by(builder)
49
+ @round_robin_builder = builder
50
+ end
51
+
52
+ def round_robin?
53
+ !@round_robin_builder.nil?
54
+ end
55
+
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
60
+ end
61
+
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
68
+
69
+ def build_snapshot(arguments)
70
+ @snapshots.transform_values { |builder| builder.call(arguments) }
71
+ end
72
+ end
73
+ end