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.
- checksums.yaml +4 -4
- data/MIT-LICENSE +16 -17
- data/README.md +449 -288
- data/app/assets/stylesheets/dispatch_policy/application.css +157 -0
- data/app/controllers/dispatch_policy/application_controller.rb +45 -1
- data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
- data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
- data/app/controllers/dispatch_policy/policies_controller.rb +94 -241
- data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
- data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
- data/app/models/dispatch_policy/inflight_job.rb +12 -0
- data/app/models/dispatch_policy/partition.rb +21 -0
- data/app/models/dispatch_policy/staged_job.rb +4 -97
- data/app/models/dispatch_policy/tick_sample.rb +11 -0
- data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
- data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
- data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
- data/app/views/dispatch_policy/policies/index.html.erb +15 -37
- data/app/views/dispatch_policy/policies/show.html.erb +140 -216
- data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
- data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
- data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
- data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
- data/app/views/layouts/dispatch_policy/application.html.erb +95 -238
- data/config/routes.rb +18 -2
- data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
- data/lib/dispatch_policy/bypass.rb +23 -0
- data/lib/dispatch_policy/config.rb +85 -0
- data/lib/dispatch_policy/context.rb +50 -0
- data/lib/dispatch_policy/cursor_pagination.rb +121 -0
- data/lib/dispatch_policy/decision.rb +22 -0
- data/lib/dispatch_policy/engine.rb +4 -27
- data/lib/dispatch_policy/forwarder.rb +63 -0
- data/lib/dispatch_policy/gate.rb +10 -38
- data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
- data/lib/dispatch_policy/gates/concurrency.rb +45 -26
- data/lib/dispatch_policy/gates/throttle.rb +65 -37
- data/lib/dispatch_policy/inflight_tracker.rb +174 -0
- data/lib/dispatch_policy/job_extension.rb +155 -0
- data/lib/dispatch_policy/operator_hints.rb +126 -0
- data/lib/dispatch_policy/pipeline.rb +48 -0
- data/lib/dispatch_policy/policy.rb +62 -47
- data/lib/dispatch_policy/policy_dsl.rb +120 -0
- data/lib/dispatch_policy/railtie.rb +35 -0
- data/lib/dispatch_policy/registry.rb +46 -0
- data/lib/dispatch_policy/repository.rb +723 -0
- data/lib/dispatch_policy/serializer.rb +36 -0
- data/lib/dispatch_policy/tick.rb +263 -172
- data/lib/dispatch_policy/tick_loop.rb +59 -26
- data/lib/dispatch_policy/version.rb +1 -1
- data/lib/dispatch_policy.rb +71 -46
- data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
- data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
- data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
- data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
- metadata +101 -43
- data/CHANGELOG.md +0 -12
- data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
- data/app/models/dispatch_policy/partition_observation.rb +0 -49
- data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
- data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
- data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
- data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
- data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
- data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
- data/lib/dispatch_policy/dispatch_context.rb +0 -53
- data/lib/dispatch_policy/dispatchable.rb +0 -120
- data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
- data/lib/dispatch_policy/gates/global_cap.rb +0 -26
- 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
|