dispatch_policy 0.2.0 → 0.4.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/CHANGELOG.md +98 -28
- data/MIT-LICENSE +16 -17
- data/README.md +452 -388
- data/app/assets/images/dispatch_policy/logo-large.svg +9 -0
- data/app/assets/images/dispatch_policy/logo-small.svg +7 -0
- data/app/assets/javascripts/dispatch_policy/turbo.es2017-umd.min.js +35 -0
- data/app/assets/stylesheets/dispatch_policy/application.css +294 -0
- data/app/controllers/dispatch_policy/application_controller.rb +45 -1
- data/app/controllers/dispatch_policy/assets_controller.rb +31 -0
- 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 -267
- 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 +139 -223
- 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 +164 -231
- data/config/routes.rb +21 -2
- data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
- data/lib/dispatch_policy/assets.rb +38 -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 +5 -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 -41
- 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 +61 -59
- 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 +260 -256
- data/lib/dispatch_policy/tick_loop.rb +59 -26
- data/lib/dispatch_policy/version.rb +1 -1
- data/lib/dispatch_policy.rb +72 -52
- 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 +134 -42
- data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
- data/app/models/dispatch_policy/partition_observation.rb +0 -76
- 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/db/migrate/20260425000001_add_duration_to_partition_observations.rb +0 -8
- 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 -123
- data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
- data/lib/dispatch_policy/gates/global_cap.rb +0 -26
|
@@ -1,89 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module DispatchPolicy
|
|
4
|
+
# One row per (policy_name, partition_key) for partitions whose policy
|
|
5
|
+
# declares an `:adaptive_concurrency` gate. Holds the AIMD-tuned
|
|
6
|
+
# `current_max` plus the EWMA of recent queue-lag observations the cap
|
|
7
|
+
# adapts on.
|
|
8
|
+
#
|
|
9
|
+
# Read by `Gates::AdaptiveConcurrency#evaluate` to learn how many jobs
|
|
10
|
+
# this partition may admit right now. Written atomically by
|
|
11
|
+
# `Repository.adaptive_record!` from `InflightTracker.track`'s ensure
|
|
12
|
+
# block after each perform — the EWMA + AIMD update lives in a single
|
|
13
|
+
# SQL statement so concurrent workers can't race on read-modify-write.
|
|
4
14
|
class AdaptiveConcurrencyStats < ApplicationRecord
|
|
5
15
|
self.table_name = "dispatch_policy_adaptive_concurrency_stats"
|
|
6
16
|
|
|
7
|
-
|
|
8
|
-
def self.seed!(policy_name:, gate_name:, partition_key:, initial_max:)
|
|
9
|
-
now = Time.current
|
|
10
|
-
sql = <<~SQL.squish
|
|
11
|
-
INSERT INTO #{quoted_table_name}
|
|
12
|
-
(policy_name, gate_name, partition_key, current_max,
|
|
13
|
-
ewma_latency_ms, sample_count, created_at, updated_at)
|
|
14
|
-
VALUES (?, ?, ?, ?, 0, 0, ?, ?)
|
|
15
|
-
ON CONFLICT (policy_name, gate_name, partition_key) DO NOTHING
|
|
16
|
-
SQL
|
|
17
|
-
connection.exec_update(
|
|
18
|
-
sanitize_sql_array([
|
|
19
|
-
sql, policy_name, gate_name.to_s, partition_key.to_s,
|
|
20
|
-
initial_max.to_i, now, now
|
|
21
|
-
])
|
|
22
|
-
)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def self.fetch_many(policy_name:, gate_name:, partition_keys:)
|
|
26
|
-
return {} if partition_keys.empty?
|
|
27
|
-
where(policy_name: policy_name, gate_name: gate_name.to_s, partition_key: partition_keys)
|
|
28
|
-
.pluck(:partition_key, :current_max, :ewma_latency_ms)
|
|
29
|
-
.each_with_object({}) { |(k, c, l), h| h[k] = { current_max: c, ewma_latency_ms: l } }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Single-statement EWMA + AIMD update so concurrent performs can't race
|
|
33
|
-
# on read-modify-write. Seed first (INSERT ON CONFLICT DO NOTHING), then
|
|
34
|
-
# apply the adjustment.
|
|
35
|
-
def self.record_observation!(
|
|
36
|
-
policy_name:, gate_name:, partition_key:,
|
|
37
|
-
queue_lag_ms:, succeeded:,
|
|
38
|
-
alpha:, min:, target_lag_ms:,
|
|
39
|
-
fail_factor:, slow_factor:, initial_max:
|
|
40
|
-
)
|
|
41
|
-
seed!(
|
|
42
|
-
policy_name: policy_name,
|
|
43
|
-
gate_name: gate_name,
|
|
44
|
-
partition_key: partition_key,
|
|
45
|
-
initial_max: initial_max
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
# Feedback signal is queue_lag (admitted_at → perform_start). When
|
|
49
|
-
# the adapter queue is empty, lag ≈ 0 → +1 grow. When the queue
|
|
50
|
-
# backs up, lag rises past target → multiplicative shrink. Failures
|
|
51
|
-
# shrink harder. Only `min` is enforced so a partition can't lock
|
|
52
|
-
# out entirely.
|
|
53
|
-
sql = <<~SQL.squish
|
|
54
|
-
UPDATE #{quoted_table_name}
|
|
55
|
-
SET
|
|
56
|
-
ewma_latency_ms = ewma_latency_ms * (1 - ?) + ? * ?,
|
|
57
|
-
sample_count = sample_count + 1,
|
|
58
|
-
current_max = GREATEST(?, CASE
|
|
59
|
-
WHEN ? = FALSE THEN FLOOR(current_max * ?)::int
|
|
60
|
-
WHEN (ewma_latency_ms * (1 - ?) + ? * ?) > ? THEN FLOOR(current_max * ?)::int
|
|
61
|
-
ELSE current_max + 1
|
|
62
|
-
END),
|
|
63
|
-
last_observed_at = ?,
|
|
64
|
-
updated_at = ?
|
|
65
|
-
WHERE policy_name = ? AND gate_name = ? AND partition_key = ?
|
|
66
|
-
SQL
|
|
67
|
-
|
|
68
|
-
now = Time.current
|
|
69
|
-
connection.exec_update(
|
|
70
|
-
sanitize_sql_array([
|
|
71
|
-
sql,
|
|
72
|
-
alpha, alpha, queue_lag_ms,
|
|
73
|
-
min.to_i,
|
|
74
|
-
succeeded, fail_factor,
|
|
75
|
-
alpha, alpha, queue_lag_ms, target_lag_ms, slow_factor,
|
|
76
|
-
now, now,
|
|
77
|
-
policy_name, gate_name.to_s, partition_key.to_s
|
|
78
|
-
])
|
|
79
|
-
)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Quick lookup used by Dispatchable to denormalize current_max into
|
|
83
|
-
# the generic partition observation row.
|
|
84
|
-
def self.current_max_for(policy_name:, partition_key:)
|
|
85
|
-
where(policy_name: policy_name, partition_key: partition_key.to_s)
|
|
86
|
-
.limit(1).pick(:current_max)
|
|
87
|
-
end
|
|
17
|
+
scope :for_policy, ->(name) { where(policy_name: name) }
|
|
88
18
|
end
|
|
89
19
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
class InflightJob < ApplicationRecord
|
|
5
|
+
self.table_name = "dispatch_policy_inflight_jobs"
|
|
6
|
+
|
|
7
|
+
scope :for_partition, ->(policy_name, partition_key) {
|
|
8
|
+
where(policy_name: policy_name, partition_key: partition_key)
|
|
9
|
+
}
|
|
10
|
+
scope :stale, ->(cutoff) { where("heartbeat_at < ?", cutoff) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
class Partition < ApplicationRecord
|
|
5
|
+
self.table_name = "dispatch_policy_partitions"
|
|
6
|
+
|
|
7
|
+
scope :for_policy, ->(name) { where(policy_name: name) }
|
|
8
|
+
scope :for_shard, ->(s) { s ? where(shard: s) : all }
|
|
9
|
+
scope :active, -> { where(status: "active") }
|
|
10
|
+
scope :paused, -> { where(status: "paused") }
|
|
11
|
+
scope :pending, -> { where("pending_count > 0") }
|
|
12
|
+
scope :stale_inactive, ->(cutoff) {
|
|
13
|
+
where("pending_count = 0 AND in_flight_count = 0")
|
|
14
|
+
.where("last_admit_at < ? OR (last_admit_at IS NULL AND created_at < ?)", cutoff, cutoff)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def paused?
|
|
18
|
+
status == "paused"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -4,102 +4,9 @@ module DispatchPolicy
|
|
|
4
4
|
class StagedJob < ApplicationRecord
|
|
5
5
|
self.table_name = "dispatch_policy_staged_jobs"
|
|
6
6
|
|
|
7
|
-
scope :
|
|
8
|
-
scope :
|
|
9
|
-
scope :
|
|
10
|
-
scope :
|
|
11
|
-
scope :expired_leases, -> {
|
|
12
|
-
admitted.where("lease_expires_at IS NOT NULL AND lease_expires_at < ?", Time.current)
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
# Merge the job's ActiveJob metadata (queue_name, priority) into the
|
|
16
|
-
# context hash so gate lambdas can partition_by :queue_name without
|
|
17
|
-
# the user having to pass it as a kwarg. User-provided keys win.
|
|
18
|
-
def self.context_for(job_instance, policy)
|
|
19
|
-
built = policy.context_builder.call(job_instance.arguments)
|
|
20
|
-
return built unless built.is_a?(Hash)
|
|
21
|
-
{
|
|
22
|
-
queue_name: job_instance.queue_name,
|
|
23
|
-
priority: job_instance.priority
|
|
24
|
-
}.merge(built.symbolize_keys)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Stages a job in the admission queue. Returns the created row, or nil if
|
|
28
|
-
# the policy declares a dedupe_key and an active row already exists.
|
|
29
|
-
def self.stage!(job_instance:, policy:)
|
|
30
|
-
dedupe_key = policy.build_dedupe_key(job_instance.arguments)
|
|
31
|
-
|
|
32
|
-
if dedupe_key && exists?(policy_name: policy.name, dedupe_key: dedupe_key, completed_at: nil)
|
|
33
|
-
return nil
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
create!(
|
|
37
|
-
job_class: job_instance.class.name,
|
|
38
|
-
policy_name: policy.name,
|
|
39
|
-
arguments: job_instance.serialize,
|
|
40
|
-
snapshot: policy.build_snapshot(job_instance.arguments),
|
|
41
|
-
context: context_for(job_instance, policy),
|
|
42
|
-
priority: job_instance.priority || 100,
|
|
43
|
-
not_before_at: job_instance.scheduled_at,
|
|
44
|
-
staged_at: Time.current,
|
|
45
|
-
dedupe_key: dedupe_key,
|
|
46
|
-
round_robin_key: policy.build_round_robin_key(job_instance.arguments)
|
|
47
|
-
)
|
|
48
|
-
rescue ActiveRecord::RecordNotUnique
|
|
49
|
-
nil
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Batch-insert variant of stage!.
|
|
53
|
-
def self.stage_many!(policy:, jobs:)
|
|
54
|
-
return 0 if jobs.empty?
|
|
55
|
-
|
|
56
|
-
now = Time.current
|
|
57
|
-
rows = jobs.map do |job_instance|
|
|
58
|
-
{
|
|
59
|
-
job_class: job_instance.class.name,
|
|
60
|
-
policy_name: policy.name,
|
|
61
|
-
arguments: job_instance.serialize,
|
|
62
|
-
snapshot: policy.build_snapshot(job_instance.arguments),
|
|
63
|
-
context: context_for(job_instance, policy),
|
|
64
|
-
priority: job_instance.priority || 100,
|
|
65
|
-
not_before_at: job_instance.scheduled_at,
|
|
66
|
-
staged_at: now,
|
|
67
|
-
dedupe_key: policy.build_dedupe_key(job_instance.arguments),
|
|
68
|
-
round_robin_key: policy.build_round_robin_key(job_instance.arguments),
|
|
69
|
-
partitions: {},
|
|
70
|
-
created_at: now,
|
|
71
|
-
updated_at: now
|
|
72
|
-
}
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
result = insert_all(rows, unique_by: :idx_dp_staged_dedupe_active)
|
|
76
|
-
result.rows.size
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def self.mark_completed_by_active_job_id(active_job_id)
|
|
80
|
-
return 0 if active_job_id.blank?
|
|
81
|
-
where(active_job_id: active_job_id, completed_at: nil)
|
|
82
|
-
.update_all(completed_at: Time.current, lease_expires_at: nil)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def mark_admitted!(partitions:)
|
|
86
|
-
now = Time.current
|
|
87
|
-
job = instantiate_active_job
|
|
88
|
-
job._dispatch_partitions = partitions
|
|
89
|
-
job._dispatch_admitted_at = now
|
|
90
|
-
|
|
91
|
-
update!(
|
|
92
|
-
admitted_at: now,
|
|
93
|
-
lease_expires_at: now + DispatchPolicy.config.lease_duration,
|
|
94
|
-
active_job_id: job.job_id,
|
|
95
|
-
partitions: partitions
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
job
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def instantiate_active_job
|
|
102
|
-
ActiveJob::Base.deserialize(arguments)
|
|
103
|
-
end
|
|
7
|
+
scope :for_policy, ->(name) { where(policy_name: name) }
|
|
8
|
+
scope :for_partition, ->(name, key) { where(policy_name: name, partition_key: key) }
|
|
9
|
+
scope :due, -> { where("scheduled_at IS NULL OR scheduled_at <= now()") }
|
|
10
|
+
scope :recent, -> { order(enqueued_at: :desc) }
|
|
104
11
|
end
|
|
105
12
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
class TickSample < ApplicationRecord
|
|
5
|
+
self.table_name = "dispatch_policy_tick_samples"
|
|
6
|
+
|
|
7
|
+
scope :for_policy, ->(name) { where(policy_name: name) }
|
|
8
|
+
scope :since, ->(time) { where("sampled_at >= ?", time) }
|
|
9
|
+
scope :recent, -> { order(sampled_at: :desc) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<section class="dp-stats">
|
|
2
|
+
<div class="dp-stat"><span class="dp-stat-label">Staged</span><span class="dp-stat-value"><%= format_count(@totals[:staged]) %></span></div>
|
|
3
|
+
<div class="dp-stat"><span class="dp-stat-label">Partitions</span><span class="dp-stat-value"><%= format_count(@totals[:partitions]) %></span></div>
|
|
4
|
+
<div class="dp-stat"><span class="dp-stat-label">Active</span><span class="dp-stat-value"><%= format_count(@totals[:active_parts]) %></span></div>
|
|
5
|
+
<div class="dp-stat"><span class="dp-stat-label">Paused</span><span class="dp-stat-value <%= "dp-warn" if @totals[:paused_parts].positive? %>"><%= format_count(@totals[:paused_parts]) %></span></div>
|
|
6
|
+
<div class="dp-stat"><span class="dp-stat-label">In flight</span><span class="dp-stat-value"><%= format_count(@totals[:in_flight]) %></span></div>
|
|
7
|
+
</section>
|
|
8
|
+
|
|
9
|
+
<%= render "dispatch_policy/shared/hints", hints: @hints %>
|
|
10
|
+
<%= render "dispatch_policy/shared/capacity",
|
|
11
|
+
capacity: @capacity,
|
|
12
|
+
pending_trend: @pending_trend,
|
|
13
|
+
pending_buckets: @pending_buckets %>
|
|
14
|
+
|
|
15
|
+
<section class="dp-section">
|
|
16
|
+
<h2>Throughput</h2>
|
|
17
|
+
<table class="dp-table">
|
|
18
|
+
<thead>
|
|
19
|
+
<tr><th>Window</th><th class="dp-num">Jobs admitted</th><th class="dp-num">Ticks</th><th class="dp-num">Avg tick</th><th class="dp-num">Max tick</th><th class="dp-num">Forward fail</th></tr>
|
|
20
|
+
</thead>
|
|
21
|
+
<tbody>
|
|
22
|
+
<% @windows.each do |label, m| %>
|
|
23
|
+
<% fail_pct = m[:jobs_admitted].positive? ? (m[:forward_failures].to_f / m[:jobs_admitted] * 100) : 0.0 %>
|
|
24
|
+
<tr>
|
|
25
|
+
<td><strong><%= label %></strong></td>
|
|
26
|
+
<td class="dp-num"><%= format_count(m[:jobs_admitted]) %></td>
|
|
27
|
+
<td class="dp-num"><%= format_count(m[:ticks]) %></td>
|
|
28
|
+
<td class="dp-num"><%= format_duration_ms(m[:avg_duration_ms]) %></td>
|
|
29
|
+
<td class="dp-num"><%= format_duration_ms(m[:max_duration_ms]) %></td>
|
|
30
|
+
<td class="dp-num <%= "dp-warn" if fail_pct >= 1.0 %>">
|
|
31
|
+
<%= format_count(m[:forward_failures]) %>
|
|
32
|
+
<% if m[:jobs_admitted].positive? && m[:forward_failures].positive? %>
|
|
33
|
+
<span style="color:#9ca3af; font-size:11px;">(<%= format("%.2f%%", fail_pct) %>)</span>
|
|
34
|
+
<% end %>
|
|
35
|
+
</td>
|
|
36
|
+
</tr>
|
|
37
|
+
<% end %>
|
|
38
|
+
</tbody>
|
|
39
|
+
</table>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<section class="dp-section">
|
|
43
|
+
<h2>Round-trip across active partitions</h2>
|
|
44
|
+
<% if @round_trip[:active_partitions].zero? %>
|
|
45
|
+
<p class="dp-empty">No active partitions with pending jobs.</p>
|
|
46
|
+
<% else %>
|
|
47
|
+
<div class="dp-stats">
|
|
48
|
+
<div class="dp-stat"><span class="dp-stat-label">Active w/ pending</span><span class="dp-stat-value"><%= format_count(@round_trip[:active_partitions]) %></span></div>
|
|
49
|
+
<div class="dp-stat"><span class="dp-stat-label">Never checked</span><span class="dp-stat-value <%= "dp-warn" if @round_trip[:never_checked].positive? %>"><%= format_count(@round_trip[:never_checked]) %></span></div>
|
|
50
|
+
<div class="dp-stat"><span class="dp-stat-label">In backoff</span><span class="dp-stat-value"><%= format_count(@round_trip[:in_backoff]) %></span></div>
|
|
51
|
+
<div class="dp-stat"><span class="dp-stat-label">Oldest age</span><span class="dp-stat-value"><%= format_duration_seconds(@round_trip[:oldest_age_seconds]) %></span></div>
|
|
52
|
+
<div class="dp-stat"><span class="dp-stat-label">P50 age</span><span class="dp-stat-value"><%= format_duration_seconds(@round_trip[:p50_age_seconds]) %></span></div>
|
|
53
|
+
<div class="dp-stat"><span class="dp-stat-label">P95 age</span><span class="dp-stat-value"><%= format_duration_seconds(@round_trip[:p95_age_seconds]) %></span></div>
|
|
54
|
+
</div>
|
|
55
|
+
<p class="dp-hint">
|
|
56
|
+
If <strong>oldest age</strong> is bigger than your tolerable latency for the slowest partition, increase
|
|
57
|
+
<code>partition_batch_size</code> or shard ticks per <code>policy_name</code>.
|
|
58
|
+
If <strong>never checked</strong> stays positive across refreshes, the tick is not keeping up — same remedy.
|
|
59
|
+
</p>
|
|
60
|
+
<% end %>
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<section class="dp-section">
|
|
64
|
+
<h2>Policies</h2>
|
|
65
|
+
<% if @policies.empty? %>
|
|
66
|
+
<p class="dp-empty">No policies have produced staged jobs yet.</p>
|
|
67
|
+
<% else %>
|
|
68
|
+
<table class="dp-table">
|
|
69
|
+
<thead>
|
|
70
|
+
<tr>
|
|
71
|
+
<th>Name</th>
|
|
72
|
+
<th class="dp-num">Pending</th>
|
|
73
|
+
<th class="dp-num">In flight</th>
|
|
74
|
+
<th class="dp-num">Admit/min (1m)</th>
|
|
75
|
+
<th class="dp-num">Ticks (1m)</th>
|
|
76
|
+
<th class="dp-num">Avg tick</th>
|
|
77
|
+
<th class="dp-num">P95 age</th>
|
|
78
|
+
<th class="dp-num">Backoff</th>
|
|
79
|
+
<th>Top denial</th>
|
|
80
|
+
<th>Last admit</th>
|
|
81
|
+
<th></th>
|
|
82
|
+
</tr>
|
|
83
|
+
</thead>
|
|
84
|
+
<tbody>
|
|
85
|
+
<% @policies.each do |p| %>
|
|
86
|
+
<tr>
|
|
87
|
+
<td><%= link_to p[:name], policy_path(p[:name]), class: "dp-link" %></td>
|
|
88
|
+
<td class="dp-num"><%= format_count(p[:pending]) %></td>
|
|
89
|
+
<td class="dp-num"><%= format_count(p[:in_flight]) %></td>
|
|
90
|
+
<td class="dp-num"><%= format_count(p[:admitted_1m]) %></td>
|
|
91
|
+
<td class="dp-num"><%= format_count(p[:ticks_1m]) %></td>
|
|
92
|
+
<td class="dp-num"><%= format_duration_ms(p[:avg_tick_ms_1m]) %></td>
|
|
93
|
+
<td class="dp-num"><%= format_duration_seconds(p[:p95_age_seconds]) %></td>
|
|
94
|
+
<td class="dp-num <%= "dp-warn" if p[:in_backoff].to_i.positive? %>"><%= format_count(p[:in_backoff]) %></td>
|
|
95
|
+
<td>
|
|
96
|
+
<% if p[:top_denial_reason] %>
|
|
97
|
+
<code><%= p[:top_denial_reason] %></code> ×<%= p[:top_denial_count] %>
|
|
98
|
+
<% else %>
|
|
99
|
+
—
|
|
100
|
+
<% end %>
|
|
101
|
+
</td>
|
|
102
|
+
<td><%= format_time(p[:last_admit_at]) %></td>
|
|
103
|
+
<td><%= link_to "Partitions →", partitions_path(policy: p[:name]), class: "dp-link" %></td>
|
|
104
|
+
</tr>
|
|
105
|
+
<% end %>
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
<% end %>
|
|
109
|
+
</section>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<h1>
|
|
2
|
+
Partitions<% if @policy %> · <code><%= @policy %></code><% end %><% if @shard %> · shard <code><%= @shard %></code><% end %>
|
|
3
|
+
<small style="font-weight:400; color:#6b7280; font-size:14px;">
|
|
4
|
+
<%= format_count(@total) %> match<%= @total == 1 ? "" : "es" %>
|
|
5
|
+
</small>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
<%= form_with url: partitions_path, method: :get, local: true, class: "dp-search-form" do |f| %>
|
|
9
|
+
<%= f.hidden_field :policy, value: @policy if @policy.present? %>
|
|
10
|
+
<% if @shards.size > 1 %>
|
|
11
|
+
<%= f.select :shard, [["all shards", ""]] + @shards.map { |s| [s, s] }, { selected: @shard }, class: "dp-input" %>
|
|
12
|
+
<% elsif @shards.size == 1 %>
|
|
13
|
+
<%= f.hidden_field :shard, value: @shard if @shard.present? %>
|
|
14
|
+
<% end %>
|
|
15
|
+
<%= f.text_field :q, value: @query, placeholder: "search partition_key…", class: "dp-input" %>
|
|
16
|
+
<%= f.select :sort,
|
|
17
|
+
DispatchPolicy::CursorPagination::SORTS.map { |k, defn| ["sort: #{defn[:label]}", k] },
|
|
18
|
+
{ selected: @sort },
|
|
19
|
+
class: "dp-input" %>
|
|
20
|
+
<label style="font-size:12.5px; margin: 0 6px;">
|
|
21
|
+
<%= f.check_box :only_pending, { checked: @only_pending }, "1", "0" %>
|
|
22
|
+
only pending>0
|
|
23
|
+
</label>
|
|
24
|
+
<%= f.submit "Apply", class: "dp-btn" %>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<% if @partitions.any? %>
|
|
28
|
+
<table class="dp-table">
|
|
29
|
+
<thead>
|
|
30
|
+
<tr>
|
|
31
|
+
<th>Policy</th><th>Shard</th><th>Queue</th><th>Partition key</th><th>Status</th>
|
|
32
|
+
<th class="dp-num">Pending</th><th class="dp-num">Lifetime</th>
|
|
33
|
+
<th>Next eligible</th><th>Last admit</th><th>Last enq</th>
|
|
34
|
+
</tr>
|
|
35
|
+
</thead>
|
|
36
|
+
<tbody>
|
|
37
|
+
<% @partitions.each do |p| %>
|
|
38
|
+
<%= render "dispatch_policy/shared/partition_row", partition: p %>
|
|
39
|
+
<% end %>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
|
|
43
|
+
<div class="dp-pagination">
|
|
44
|
+
<span class="dp-pagination-info">
|
|
45
|
+
<%= format_count(@partitions.size) %> shown
|
|
46
|
+
<% if @cursor %> · paged via cursor — use the browser back button to go back <% end %>
|
|
47
|
+
</span>
|
|
48
|
+
<span class="dp-pagination-nav">
|
|
49
|
+
<% if @cursor %>
|
|
50
|
+
<%= link_to "« first", partitions_path(pagination_params(cursor: nil)), class: "dp-btn" %>
|
|
51
|
+
<% else %>
|
|
52
|
+
<span class="dp-btn dp-btn-disabled">« first</span>
|
|
53
|
+
<% end %>
|
|
54
|
+
<% if @next_cursor %>
|
|
55
|
+
<%= link_to "next ›", partitions_path(pagination_params(cursor: @next_cursor)), class: "dp-btn" %>
|
|
56
|
+
<% else %>
|
|
57
|
+
<span class="dp-btn dp-btn-disabled">next ›</span>
|
|
58
|
+
<% end %>
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
<% else %>
|
|
62
|
+
<p class="dp-empty">No partitions match.</p>
|
|
63
|
+
<% end %>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<h1>
|
|
2
|
+
<%= link_to "← all partitions", partitions_path(policy: @partition.policy_name), class: "dp-link" %>
|
|
3
|
+
/
|
|
4
|
+
<code><%= @partition.partition_key %></code>
|
|
5
|
+
</h1>
|
|
6
|
+
|
|
7
|
+
<% in_backoff = @partition.next_eligible_at && @partition.next_eligible_at > Time.current %>
|
|
8
|
+
<% age_seconds = @partition.last_checked_at && (Time.current - @partition.last_checked_at) %>
|
|
9
|
+
<% half_life = (DispatchPolicy.registry.fetch(@partition.policy_name)&.fairness_half_life_seconds || DispatchPolicy.config.fairness_half_life_seconds).to_f %>
|
|
10
|
+
<%
|
|
11
|
+
decayed_now =
|
|
12
|
+
if @partition.decayed_admits_at && half_life.positive?
|
|
13
|
+
tau = half_life / Math.log(2)
|
|
14
|
+
elapsed = [Time.current.to_f - @partition.decayed_admits_at.to_f, 0.0].max
|
|
15
|
+
@partition.decayed_admits.to_f * Math.exp(-elapsed / tau)
|
|
16
|
+
else
|
|
17
|
+
@partition.decayed_admits.to_f
|
|
18
|
+
end
|
|
19
|
+
admits_per_min_estimate = decayed_now * (Math.log(2) / half_life) * 60.0 if half_life.positive?
|
|
20
|
+
%>
|
|
21
|
+
|
|
22
|
+
<section class="dp-stats">
|
|
23
|
+
<div class="dp-stat"><span class="dp-stat-label">Policy</span><span class="dp-stat-value"><%= @partition.policy_name %></span></div>
|
|
24
|
+
<div class="dp-stat"><span class="dp-stat-label">Shard</span><span class="dp-stat-value"><code><%= @partition.shard %></code></span></div>
|
|
25
|
+
<div class="dp-stat"><span class="dp-stat-label">Queue</span><span class="dp-stat-value"><%= @partition.queue_name || "—" %></span></div>
|
|
26
|
+
<div class="dp-stat"><span class="dp-stat-label">Status</span><span class="dp-stat-value <%= "dp-warn" if @partition.paused? %>"><%= @partition.status %></span></div>
|
|
27
|
+
<div class="dp-stat"><span class="dp-stat-label">Pending</span><span class="dp-stat-value"><%= format_count(@partition.pending_count) %></span></div>
|
|
28
|
+
<div class="dp-stat"><span class="dp-stat-label">Lifetime admitted</span><span class="dp-stat-value"><%= format_count(@partition.total_admitted) %></span></div>
|
|
29
|
+
<div class="dp-stat"><span class="dp-stat-label">Round-trip age</span><span class="dp-stat-value"><%= age_seconds ? format_duration_seconds(age_seconds) : "never" %></span></div>
|
|
30
|
+
<div class="dp-stat"><span class="dp-stat-label">Backoff</span><span class="dp-stat-value <%= "dp-warn" if in_backoff %>"><%= in_backoff ? "until #{format_time(@partition.next_eligible_at)}" : "—" %></span></div>
|
|
31
|
+
<div class="dp-stat">
|
|
32
|
+
<span class="dp-stat-label">Recent admits (EWMA)</span>
|
|
33
|
+
<span class="dp-stat-value"><%= format("%.2f", decayed_now) %></span>
|
|
34
|
+
</div>
|
|
35
|
+
<% if admits_per_min_estimate %>
|
|
36
|
+
<div class="dp-stat">
|
|
37
|
+
<span class="dp-stat-label">≈ admits/min</span>
|
|
38
|
+
<span class="dp-stat-value"><%= format("%.1f", admits_per_min_estimate) %></span>
|
|
39
|
+
</div>
|
|
40
|
+
<% end %>
|
|
41
|
+
</section>
|
|
42
|
+
|
|
43
|
+
<section class="dp-hint">
|
|
44
|
+
<strong>Recent admits (EWMA)</strong> is an exponentially weighted count of admissions for this partition,
|
|
45
|
+
with a half-life of <%= half_life.to_i %>s. The Tick reorders claimed partitions by this value ASC, so the
|
|
46
|
+
least-recently-active ones get first crack at the tick budget. The "≈ admits/min" line reads it as the
|
|
47
|
+
steady-state admission rate.
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
<section class="dp-section">
|
|
51
|
+
<h2>Timing</h2>
|
|
52
|
+
<ul class="dp-list">
|
|
53
|
+
<li>Last checked: <%= format_time(@partition.last_checked_at) %></li>
|
|
54
|
+
<li>Last admit: <%= format_time(@partition.last_admit_at) %></li>
|
|
55
|
+
<li>Last enqueued: <%= format_time(@partition.last_enqueued_at) %></li>
|
|
56
|
+
<li>Next eligible: <%= format_time(@partition.next_eligible_at) %></li>
|
|
57
|
+
<li>Context updated: <%= format_time(@partition.context_updated_at) %></li>
|
|
58
|
+
</ul>
|
|
59
|
+
</section>
|
|
60
|
+
|
|
61
|
+
<section class="dp-section">
|
|
62
|
+
<h2>Context (refreshed on each enqueue)</h2>
|
|
63
|
+
<pre class="dp-json"><%= JSON.pretty_generate(@partition.context || {}) %></pre>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<section class="dp-section">
|
|
67
|
+
<h2>Gate state</h2>
|
|
68
|
+
<pre class="dp-json"><%= JSON.pretty_generate(@partition.gate_state || {}) %></pre>
|
|
69
|
+
</section>
|
|
70
|
+
|
|
71
|
+
<section class="dp-section">
|
|
72
|
+
<h2>Recent staged jobs (max 50)</h2>
|
|
73
|
+
<% if @recent_jobs.any? %>
|
|
74
|
+
<table class="dp-table">
|
|
75
|
+
<thead><tr><th>id</th><th>Job class</th><th>Scheduled</th><th>Enqueued</th><th></th></tr></thead>
|
|
76
|
+
<tbody>
|
|
77
|
+
<% @recent_jobs.each do |j| %>
|
|
78
|
+
<tr>
|
|
79
|
+
<td><%= j.id %></td>
|
|
80
|
+
<td><code><%= j.job_class %></code></td>
|
|
81
|
+
<td><%= format_time(j.scheduled_at) %></td>
|
|
82
|
+
<td><%= format_time(j.enqueued_at) %></td>
|
|
83
|
+
<td><%= link_to "inspect", staged_job_path(j), class: "dp-link" %></td>
|
|
84
|
+
</tr>
|
|
85
|
+
<% end %>
|
|
86
|
+
</tbody>
|
|
87
|
+
</table>
|
|
88
|
+
<% else %>
|
|
89
|
+
<p class="dp-empty">No staged jobs in this partition.</p>
|
|
90
|
+
<% end %>
|
|
91
|
+
</section>
|
|
92
|
+
|
|
93
|
+
<section class="dp-section">
|
|
94
|
+
<h2>Actions</h2>
|
|
95
|
+
<%= button_to "Force admit 1", admit_partition_path(@partition, count: 1), class: "dp-btn", method: :post, form: { class: "dp-form-inline" } %>
|
|
96
|
+
<%= button_to "Force admit 10", admit_partition_path(@partition, count: 10), class: "dp-btn", method: :post, form: { class: "dp-form-inline" } %>
|
|
97
|
+
<%= button_to "Drain partition", drain_partition_path(@partition),
|
|
98
|
+
class: "dp-btn dp-btn-warn",
|
|
99
|
+
method: :post,
|
|
100
|
+
form: { class: "dp-form-inline",
|
|
101
|
+
onsubmit: "return confirm('Force-admit every staged job in this partition, bypassing all gates? This sends them to the real adapter immediately.');" } %>
|
|
102
|
+
<p class="dp-hint">
|
|
103
|
+
<strong>Drain</strong> empties the partition by sending every staged job to the real adapter without consulting throttle/concurrency gates.
|
|
104
|
+
Capped at 10,000 jobs per click — click again for more.
|
|
105
|
+
</p>
|
|
106
|
+
</section>
|
|
@@ -1,49 +1,27 @@
|
|
|
1
|
-
<h1>
|
|
2
|
-
<p class="muted">Admission-control policies registered in this app.</p>
|
|
1
|
+
<h1>Policies</h1>
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
<
|
|
6
|
-
<div class="label">Active partitions</div>
|
|
7
|
-
<div class="value"><%= @active_partitions %></div>
|
|
8
|
-
</div>
|
|
9
|
-
<div class="summary-item">
|
|
10
|
-
<div class="label">Expired leases</div>
|
|
11
|
-
<div class="value"><%= @expired_leases %></div>
|
|
12
|
-
</div>
|
|
13
|
-
</div>
|
|
14
|
-
|
|
15
|
-
<h2>Policies</h2>
|
|
16
|
-
<% if @policies.empty? %>
|
|
17
|
-
<p class="muted">No policies registered yet.</p>
|
|
3
|
+
<% if @rows.empty? %>
|
|
4
|
+
<p class="dp-empty">No policies registered or observed.</p>
|
|
18
5
|
<% else %>
|
|
19
|
-
<table>
|
|
6
|
+
<table class="dp-table">
|
|
20
7
|
<thead>
|
|
21
8
|
<tr>
|
|
22
|
-
<th>
|
|
23
|
-
<th>
|
|
24
|
-
<th>Pending</th>
|
|
25
|
-
<th>Admitted</th>
|
|
26
|
-
<th>Completed (24h)</th>
|
|
27
|
-
<th>Oldest pending</th>
|
|
9
|
+
<th>Name</th><th class="dp-num">Pending</th><th class="dp-num">In flight</th>
|
|
10
|
+
<th class="dp-num">Partitions</th><th class="dp-num">Paused</th><th>Registered</th><th></th>
|
|
28
11
|
</tr>
|
|
29
12
|
</thead>
|
|
30
13
|
<tbody>
|
|
31
|
-
<% @
|
|
14
|
+
<% @rows.each do |row| %>
|
|
32
15
|
<tr>
|
|
33
|
-
<td><%= link_to
|
|
34
|
-
<td
|
|
35
|
-
<td><%=
|
|
36
|
-
<td><%=
|
|
37
|
-
<td><%=
|
|
16
|
+
<td><%= link_to row[:name], policy_path(row[:name]), class: "dp-link" %></td>
|
|
17
|
+
<td class="dp-num"><%= format_count(row[:pending]) %></td>
|
|
18
|
+
<td class="dp-num"><%= format_count(row[:in_flight]) %></td>
|
|
19
|
+
<td class="dp-num"><%= format_count(row[:partitions]) %></td>
|
|
20
|
+
<td class="dp-num"><%= row[:paused_count].positive? ? content_tag(:span, format_count(row[:paused_count]), class: "dp-warn") : 0 %></td>
|
|
21
|
+
<td><%= row[:registered] ? "yes" : content_tag(:span, "no (orphan)", class: "dp-warn") %></td>
|
|
38
22
|
<td>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<span class="<%= 'stale' if stale %>">
|
|
42
|
-
<%= time_ago_in_words(p[:oldest_pending]) %> ago
|
|
43
|
-
</span>
|
|
44
|
-
<% else %>
|
|
45
|
-
<span class="muted">—</span>
|
|
46
|
-
<% end %>
|
|
23
|
+
<%= button_to "Pause", pause_policy_path(row[:name]), class: "dp-btn", method: :post, form: { class: "dp-form-inline" } %>
|
|
24
|
+
<%= button_to "Resume", resume_policy_path(row[:name]), class: "dp-btn dp-btn-ok", method: :post, form: { class: "dp-form-inline" } %>
|
|
47
25
|
</td>
|
|
48
26
|
</tr>
|
|
49
27
|
<% end %>
|