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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -28
  3. data/MIT-LICENSE +16 -17
  4. data/README.md +452 -388
  5. data/app/assets/images/dispatch_policy/logo-large.svg +9 -0
  6. data/app/assets/images/dispatch_policy/logo-small.svg +7 -0
  7. data/app/assets/javascripts/dispatch_policy/turbo.es2017-umd.min.js +35 -0
  8. data/app/assets/stylesheets/dispatch_policy/application.css +294 -0
  9. data/app/controllers/dispatch_policy/application_controller.rb +45 -1
  10. data/app/controllers/dispatch_policy/assets_controller.rb +31 -0
  11. data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
  12. data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
  13. data/app/controllers/dispatch_policy/policies_controller.rb +94 -267
  14. data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
  15. data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
  16. data/app/models/dispatch_policy/inflight_job.rb +12 -0
  17. data/app/models/dispatch_policy/partition.rb +21 -0
  18. data/app/models/dispatch_policy/staged_job.rb +4 -97
  19. data/app/models/dispatch_policy/tick_sample.rb +11 -0
  20. data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
  21. data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
  22. data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
  23. data/app/views/dispatch_policy/policies/index.html.erb +15 -37
  24. data/app/views/dispatch_policy/policies/show.html.erb +139 -223
  25. data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
  26. data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
  27. data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
  28. data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
  29. data/app/views/layouts/dispatch_policy/application.html.erb +164 -231
  30. data/config/routes.rb +21 -2
  31. data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
  32. data/lib/dispatch_policy/assets.rb +38 -0
  33. data/lib/dispatch_policy/bypass.rb +23 -0
  34. data/lib/dispatch_policy/config.rb +85 -0
  35. data/lib/dispatch_policy/context.rb +50 -0
  36. data/lib/dispatch_policy/cursor_pagination.rb +121 -0
  37. data/lib/dispatch_policy/decision.rb +22 -0
  38. data/lib/dispatch_policy/engine.rb +5 -27
  39. data/lib/dispatch_policy/forwarder.rb +63 -0
  40. data/lib/dispatch_policy/gate.rb +10 -38
  41. data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
  42. data/lib/dispatch_policy/gates/concurrency.rb +45 -26
  43. data/lib/dispatch_policy/gates/throttle.rb +65 -41
  44. data/lib/dispatch_policy/inflight_tracker.rb +174 -0
  45. data/lib/dispatch_policy/job_extension.rb +155 -0
  46. data/lib/dispatch_policy/operator_hints.rb +126 -0
  47. data/lib/dispatch_policy/pipeline.rb +48 -0
  48. data/lib/dispatch_policy/policy.rb +61 -59
  49. data/lib/dispatch_policy/policy_dsl.rb +120 -0
  50. data/lib/dispatch_policy/railtie.rb +35 -0
  51. data/lib/dispatch_policy/registry.rb +46 -0
  52. data/lib/dispatch_policy/repository.rb +723 -0
  53. data/lib/dispatch_policy/serializer.rb +36 -0
  54. data/lib/dispatch_policy/tick.rb +260 -256
  55. data/lib/dispatch_policy/tick_loop.rb +59 -26
  56. data/lib/dispatch_policy/version.rb +1 -1
  57. data/lib/dispatch_policy.rb +72 -52
  58. data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
  59. data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
  60. data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
  61. data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
  62. metadata +134 -42
  63. data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
  64. data/app/models/dispatch_policy/partition_observation.rb +0 -76
  65. data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
  66. data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
  67. data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
  68. data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
  69. data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
  70. data/db/migrate/20260425000001_add_duration_to_partition_observations.rb +0 -8
  71. data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
  72. data/lib/dispatch_policy/dispatch_context.rb +0 -53
  73. data/lib/dispatch_policy/dispatchable.rb +0 -123
  74. data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
  75. 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
- # Seed a stats row if one doesn't exist yet. Mirrors ThrottleBucket.lock.
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 :pending, -> { where(admitted_at: nil, completed_at: nil) }
8
- scope :admitted, -> { where.not(admitted_at: nil).where(completed_at: nil) }
9
- scope :completed, -> { where.not(completed_at: nil) }
10
- scope :active, -> { where(completed_at: nil) }
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&gt;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>DispatchPolicy</h1>
2
- <p class="muted">Admission-control policies registered in this app.</p>
1
+ <h1>Policies</h1>
3
2
 
4
- <div class="summary">
5
- <div class="summary-item">
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>Policy</th>
23
- <th>Job class</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
- <% @policies.each do |p| %>
14
+ <% @rows.each do |row| %>
32
15
  <tr>
33
- <td><%= link_to p[:name], policy_path(policy_name: p[:name]) %></td>
34
- <td><code><%= p[:job_class].name %></code></td>
35
- <td><%= p[:pending_count] %></td>
36
- <td><%= p[:admitted_count] %></td>
37
- <td><%= p[:completed_24h] %></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
- <% if p[:oldest_pending] %>
40
- <% stale = p[:oldest_pending] < Time.current - p[:stale_threshold] %>
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 %>