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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/MIT-LICENSE +21 -0
- data/README.md +435 -0
- data/app/controllers/dispatch_policy/application_controller.rb +9 -0
- data/app/controllers/dispatch_policy/policies_controller.rb +269 -0
- data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +89 -0
- data/app/models/dispatch_policy/application_record.rb +7 -0
- data/app/models/dispatch_policy/partition_inflight_count.rb +42 -0
- data/app/models/dispatch_policy/partition_observation.rb +49 -0
- data/app/models/dispatch_policy/staged_job.rb +105 -0
- data/app/models/dispatch_policy/throttle_bucket.rb +41 -0
- data/app/views/dispatch_policy/policies/index.html.erb +52 -0
- data/app/views/dispatch_policy/policies/show.html.erb +241 -0
- data/app/views/layouts/dispatch_policy/application.html.erb +266 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +80 -0
- data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +22 -0
- data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +25 -0
- data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +32 -0
- data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +32 -0
- data/lib/dispatch_policy/dispatch_context.rb +53 -0
- data/lib/dispatch_policy/dispatchable.rb +120 -0
- data/lib/dispatch_policy/engine.rb +36 -0
- data/lib/dispatch_policy/gate.rb +49 -0
- data/lib/dispatch_policy/gates/adaptive_concurrency.rb +123 -0
- data/lib/dispatch_policy/gates/concurrency.rb +43 -0
- data/lib/dispatch_policy/gates/fair_interleave.rb +32 -0
- data/lib/dispatch_policy/gates/global_cap.rb +26 -0
- data/lib/dispatch_policy/gates/throttle.rb +52 -0
- data/lib/dispatch_policy/install_generator.rb +23 -0
- data/lib/dispatch_policy/policy.rb +73 -0
- data/lib/dispatch_policy/tick.rb +214 -0
- data/lib/dispatch_policy/tick_loop.rb +45 -0
- data/lib/dispatch_policy/version.rb +5 -0
- data/lib/dispatch_policy.rb +64 -0
- metadata +182 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
<p class="back"><%= link_to "← All policies", root_path %></p>
|
|
2
|
+
<h1><%= @policy_name %> <small class="muted"><%= @job_class.name %></small></h1>
|
|
3
|
+
|
|
4
|
+
<div class="summary">
|
|
5
|
+
<div class="summary-item">
|
|
6
|
+
<div class="label">Pending</div>
|
|
7
|
+
<div class="value"><%= @pending_count %></div>
|
|
8
|
+
<div class="sub"><%= @pending_eligible_count %> eligible · <%= @pending_scheduled_count %> scheduled</div>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="summary-item">
|
|
11
|
+
<div class="label">Admitted</div>
|
|
12
|
+
<div class="value"><%= @admitted_count %></div>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="summary-item">
|
|
15
|
+
<div class="label">Completed (24h)</div>
|
|
16
|
+
<div class="value"><%= @completed_24h %></div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="summary-item">
|
|
19
|
+
<div class="label">Gates</div>
|
|
20
|
+
<div class="value" style="font-size: 1em;">
|
|
21
|
+
<% (@policy&.gates || []).each do |g| %>
|
|
22
|
+
<span class="badge"><%= g.name %></span>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<% if @adaptive_global.any? { |v| !v.nil? } %>
|
|
29
|
+
<h2>Adaptive — EWMA queue lag (avg across partitions, last hour)</h2>
|
|
30
|
+
<div class="chart-global">
|
|
31
|
+
<canvas data-chart="line"
|
|
32
|
+
data-label="avg ewma_latency_ms"
|
|
33
|
+
data-labels='<%= @chart_labels.to_json %>'
|
|
34
|
+
data-values='<%= @adaptive_global.to_json %>'
|
|
35
|
+
data-counts='<%= @completions_global.to_json %>'></canvas>
|
|
36
|
+
</div>
|
|
37
|
+
<% end %>
|
|
38
|
+
|
|
39
|
+
<h2>Watched partitions <small class="muted">(<%= @partition_breakdown.size %>)</small></h2>
|
|
40
|
+
|
|
41
|
+
<% if @partition_breakdown.empty? %>
|
|
42
|
+
<p class="muted">No partitions watched yet. Pick some from the list below with <strong>+ watch</strong>; your selection is stored locally in the browser.</p>
|
|
43
|
+
<% else %>
|
|
44
|
+
<table data-watched-table data-policy-name="<%= @policy_name %>">
|
|
45
|
+
<thead>
|
|
46
|
+
<tr>
|
|
47
|
+
<th></th>
|
|
48
|
+
<th>Source</th>
|
|
49
|
+
<th>Partition</th>
|
|
50
|
+
<th class="text-end">Pending (eligible)</th>
|
|
51
|
+
<th class="text-end">Pending (scheduled)</th>
|
|
52
|
+
<th class="text-end">In-flight</th>
|
|
53
|
+
<th class="text-end">Completed (24h)</th>
|
|
54
|
+
<th class="text-end">Adaptive cap</th>
|
|
55
|
+
<th class="text-end">EWMA latency (ms)</th>
|
|
56
|
+
</tr>
|
|
57
|
+
</thead>
|
|
58
|
+
<tbody>
|
|
59
|
+
<% @partition_breakdown.each do |row| %>
|
|
60
|
+
<tr>
|
|
61
|
+
<td><button type="button" class="chip" data-unwatch="<%= row[:partition] %>" title="Stop watching">×</button></td>
|
|
62
|
+
<td><%= row[:source] %></td>
|
|
63
|
+
<td><code><%= row[:partition] %></code></td>
|
|
64
|
+
<td class="text-end"><%= row[:eligible] %></td>
|
|
65
|
+
<td class="text-end"><%= row[:scheduled] %></td>
|
|
66
|
+
<td class="text-end"><%= row[:in_flight] %></td>
|
|
67
|
+
<td class="text-end"><%= row[:completed_24h] %></td>
|
|
68
|
+
<td class="text-end">
|
|
69
|
+
<% if row[:current_max] %><strong><%= row[:current_max] %></strong><% else %><span class="muted">—</span><% end %>
|
|
70
|
+
</td>
|
|
71
|
+
<td class="text-end">
|
|
72
|
+
<% if row[:ewma_latency_ms] %><%= row[:ewma_latency_ms] %><% else %><span class="muted">—</span><% end %>
|
|
73
|
+
</td>
|
|
74
|
+
</tr>
|
|
75
|
+
<%
|
|
76
|
+
series = @adaptive_samples[row[:partition]] || Array.new(@chart_slots.size)
|
|
77
|
+
counts = @completions_samples[row[:partition]] || Array.new(@chart_slots.size, 0)
|
|
78
|
+
has_samples = series.any? { |v| !v.nil? } || counts.any? { |c| c.positive? }
|
|
79
|
+
%>
|
|
80
|
+
<tr>
|
|
81
|
+
<td colspan="9" class="sparkline-cell">
|
|
82
|
+
<% if has_samples %>
|
|
83
|
+
<div class="sparkline-wrap">
|
|
84
|
+
<canvas data-chart="sparkline"
|
|
85
|
+
data-labels='<%= @chart_labels.to_json %>'
|
|
86
|
+
data-values='<%= series.to_json %>'
|
|
87
|
+
data-counts='<%= counts.to_json %>'></canvas>
|
|
88
|
+
</div>
|
|
89
|
+
<% else %>
|
|
90
|
+
<div class="sparkline-wrap sparkline-empty"><span class="muted">no completions in the last hour yet</span></div>
|
|
91
|
+
<% end %>
|
|
92
|
+
</td>
|
|
93
|
+
</tr>
|
|
94
|
+
<% end %>
|
|
95
|
+
</tbody>
|
|
96
|
+
</table>
|
|
97
|
+
<% end %>
|
|
98
|
+
|
|
99
|
+
<h2>All partitions <small class="muted">(<%= @partition_total_list %>)</small></h2>
|
|
100
|
+
|
|
101
|
+
<turbo-frame id="partitions_list" data-turbo-action="advance">
|
|
102
|
+
<% if @partition_total_list.to_i.zero? && @partition_search.blank? %>
|
|
103
|
+
<p class="muted">This policy declares no partitioning (no gate with <code>partition_by</code> and no <code>round_robin_by</code>).</p>
|
|
104
|
+
<% else %>
|
|
105
|
+
<%= form_with url: url_for, method: :get, local: true, html: { class: "partition-search" } do %>
|
|
106
|
+
<% if params[:watch].present? %>
|
|
107
|
+
<%= hidden_field_tag :watch, params[:watch] %>
|
|
108
|
+
<% end %>
|
|
109
|
+
<%= text_field_tag :q, @partition_search, placeholder: "filter by partition key…", size: 30, autofocus: @partition_search.present? %>
|
|
110
|
+
<%= submit_tag "Search", name: nil %>
|
|
111
|
+
<% if @partition_search.present? %>
|
|
112
|
+
<%= link_to "clear", url_for(q: nil, page: nil, watch: params[:watch]), class: "muted" %>
|
|
113
|
+
<% end %>
|
|
114
|
+
<% end %>
|
|
115
|
+
|
|
116
|
+
<% if @partition_list.empty? %>
|
|
117
|
+
<p class="muted">No matches.</p>
|
|
118
|
+
<% else %>
|
|
119
|
+
<%
|
|
120
|
+
sort_link = lambda { |label, col, align_end: false|
|
|
121
|
+
active = @partition_sort == col
|
|
122
|
+
next_dir = active && @partition_dir == "desc" ? "asc" : "desc"
|
|
123
|
+
arrow = active ? (@partition_dir == "asc" ? " ▲" : " ▼") : ""
|
|
124
|
+
content_tag(:th, class: (align_end ? "text-end" : nil)) do
|
|
125
|
+
link_to "#{label}#{arrow}",
|
|
126
|
+
url_for(sort: col, dir: next_dir, q: @partition_search.presence, page: nil, watch: params[:watch]),
|
|
127
|
+
class: (active ? "sorted" : nil)
|
|
128
|
+
end
|
|
129
|
+
}
|
|
130
|
+
%>
|
|
131
|
+
<table data-policy-name="<%= @policy_name %>">
|
|
132
|
+
<thead>
|
|
133
|
+
<tr>
|
|
134
|
+
<th></th>
|
|
135
|
+
<%= sort_link.call("Source", "source") %>
|
|
136
|
+
<%= sort_link.call("Partition", "partition") %>
|
|
137
|
+
<%= sort_link.call("Pending", "pending", align_end: true) %>
|
|
138
|
+
<%= sort_link.call("In-flight", "in_flight", align_end: true) %>
|
|
139
|
+
<%= sort_link.call("Completed (24h)", "completed_24h", align_end: true) %>
|
|
140
|
+
<%= sort_link.call("Last enqueue", "last_enqueued_at") %>
|
|
141
|
+
<%= sort_link.call("Last dispatch", "last_dispatched_at") %>
|
|
142
|
+
</tr>
|
|
143
|
+
</thead>
|
|
144
|
+
<tbody>
|
|
145
|
+
<% @partition_list.each do |row| %>
|
|
146
|
+
<% pending_total = row[:eligible] + row[:scheduled] %>
|
|
147
|
+
<tr>
|
|
148
|
+
<td>
|
|
149
|
+
<% if @watched_keys.include?(row[:partition]) %>
|
|
150
|
+
<span class="chip on" title="Watched">✓</span>
|
|
151
|
+
<% else %>
|
|
152
|
+
<button type="button" class="chip" data-watch="<%= row[:partition] %>" data-turbo-frame="_top" title="Watch this partition">+</button>
|
|
153
|
+
<% end %>
|
|
154
|
+
</td>
|
|
155
|
+
<td><%= row[:source] %></td>
|
|
156
|
+
<td><code><%= row[:partition] %></code></td>
|
|
157
|
+
<td class="text-end"><%= pending_total %></td>
|
|
158
|
+
<td class="text-end"><%= row[:in_flight] %></td>
|
|
159
|
+
<td class="text-end"><%= row[:completed_24h] %></td>
|
|
160
|
+
<td>
|
|
161
|
+
<% if row[:last_enqueued_at] %>
|
|
162
|
+
<span title="<%= row[:last_enqueued_at] %>"><%= time_ago_in_words(row[:last_enqueued_at]) %> ago</span>
|
|
163
|
+
<% else %>
|
|
164
|
+
<span class="muted">—</span>
|
|
165
|
+
<% end %>
|
|
166
|
+
</td>
|
|
167
|
+
<td>
|
|
168
|
+
<% if row[:last_dispatched_at] %>
|
|
169
|
+
<span title="<%= row[:last_dispatched_at] %>"><%= time_ago_in_words(row[:last_dispatched_at]) %> ago</span>
|
|
170
|
+
<% else %>
|
|
171
|
+
<span class="muted">—</span>
|
|
172
|
+
<% end %>
|
|
173
|
+
</td>
|
|
174
|
+
</tr>
|
|
175
|
+
<% end %>
|
|
176
|
+
</tbody>
|
|
177
|
+
</table>
|
|
178
|
+
|
|
179
|
+
<% total_pages = (@partition_total_list / DispatchPolicy::PoliciesController::PARTITION_LIST_PAGE_SIZE.to_f).ceil %>
|
|
180
|
+
<% if total_pages > 1 %>
|
|
181
|
+
<% page_params = { q: @partition_search.presence, watch: params[:watch], sort: params[:sort], dir: params[:dir] } %>
|
|
182
|
+
<div class="pagination">
|
|
183
|
+
<% if @partition_page > 1 %>
|
|
184
|
+
<%= link_to "← prev", url_for(page_params.merge(page: @partition_page - 1)) %>
|
|
185
|
+
<% end %>
|
|
186
|
+
<span class="muted">page <%= @partition_page %> / <%= total_pages %></span>
|
|
187
|
+
<% if @partition_page < total_pages %>
|
|
188
|
+
<%= link_to "next →", url_for(page_params.merge(page: @partition_page + 1)) %>
|
|
189
|
+
<% end %>
|
|
190
|
+
</div>
|
|
191
|
+
<% end %>
|
|
192
|
+
<% end %>
|
|
193
|
+
<% end %>
|
|
194
|
+
</turbo-frame>
|
|
195
|
+
|
|
196
|
+
<h2>Throttle buckets</h2>
|
|
197
|
+
<% if @throttle_buckets.empty? %>
|
|
198
|
+
<p class="muted">No throttle buckets yet.</p>
|
|
199
|
+
<% else %>
|
|
200
|
+
<table>
|
|
201
|
+
<thead>
|
|
202
|
+
<tr><th>Gate</th><th>Partition</th><th>Tokens</th><th>Refilled at</th></tr>
|
|
203
|
+
</thead>
|
|
204
|
+
<tbody>
|
|
205
|
+
<% @throttle_buckets.each do |b| %>
|
|
206
|
+
<tr>
|
|
207
|
+
<td><%= b.gate_name %></td>
|
|
208
|
+
<td><code><%= b.partition_key %></code></td>
|
|
209
|
+
<td><%= b.tokens.round(2) %></td>
|
|
210
|
+
<td><%= b.refilled_at.to_s(:short) rescue b.refilled_at %></td>
|
|
211
|
+
</tr>
|
|
212
|
+
<% end %>
|
|
213
|
+
</tbody>
|
|
214
|
+
</table>
|
|
215
|
+
<% end %>
|
|
216
|
+
|
|
217
|
+
<h2>Pending (first 50)</h2>
|
|
218
|
+
<% if @pending_jobs.empty? %>
|
|
219
|
+
<p class="muted">Nothing waiting.</p>
|
|
220
|
+
<% else %>
|
|
221
|
+
<table>
|
|
222
|
+
<thead>
|
|
223
|
+
<tr>
|
|
224
|
+
<th>ID</th><th>Dedupe key</th><th>Round-robin key</th>
|
|
225
|
+
<th>Priority</th><th>Staged at</th><th>Not before</th>
|
|
226
|
+
</tr>
|
|
227
|
+
</thead>
|
|
228
|
+
<tbody>
|
|
229
|
+
<% @pending_jobs.each do |job| %>
|
|
230
|
+
<tr>
|
|
231
|
+
<td><%= job.id %></td>
|
|
232
|
+
<td><%= job.dedupe_key ? content_tag(:code, job.dedupe_key) : content_tag(:span, '—', class: 'muted') %></td>
|
|
233
|
+
<td><%= job.round_robin_key ? content_tag(:code, job.round_robin_key) : content_tag(:span, '—', class: 'muted') %></td>
|
|
234
|
+
<td><%= job.priority %></td>
|
|
235
|
+
<td><%= job.staged_at %></td>
|
|
236
|
+
<td><%= job.not_before_at || content_tag(:span, '—', class: 'muted') %></td>
|
|
237
|
+
</tr>
|
|
238
|
+
<% end %>
|
|
239
|
+
</tbody>
|
|
240
|
+
</table>
|
|
241
|
+
<% end %>
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>DispatchPolicy</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<meta name="turbo-refresh-method" content="morph">
|
|
7
|
+
<meta name="turbo-refresh-scroll" content="preserve">
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.12/dist/turbo.es2017-umd.min.js"></script>
|
|
10
|
+
<style>
|
|
11
|
+
body { font-family: -apple-system, system-ui, sans-serif; max-width: 1200px; margin: 2em auto; padding: 0 1em; color: #222; }
|
|
12
|
+
h1 { font-size: 1.6em; margin-bottom: 0.25em; }
|
|
13
|
+
h2 { font-size: 1.2em; margin-top: 2em; border-bottom: 1px solid #ddd; padding-bottom: 0.25em; }
|
|
14
|
+
table { border-collapse: collapse; width: 100%; margin: 0.5em 0 1em; }
|
|
15
|
+
th, td { text-align: left; padding: 0.4em 0.6em; border-bottom: 1px solid #eee; }
|
|
16
|
+
th { font-size: 0.85em; text-transform: uppercase; color: #666; letter-spacing: 0.02em; }
|
|
17
|
+
code { background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.9em; }
|
|
18
|
+
.muted { color: #888; }
|
|
19
|
+
.summary { display: flex; gap: 2em; margin: 1em 0; flex-wrap: wrap; }
|
|
20
|
+
.summary-item { min-width: 120px; }
|
|
21
|
+
.summary-item .value { font-size: 1.5em; font-weight: 600; }
|
|
22
|
+
.summary-item .label { font-size: 0.8em; color: #666; text-transform: uppercase; }
|
|
23
|
+
.summary-item .sub { font-size: 0.75em; color: #888; margin-top: 0.25em; }
|
|
24
|
+
.back { font-size: 0.9em; }
|
|
25
|
+
.stale { color: #c92a2a; }
|
|
26
|
+
.badge { display: inline-block; padding: 0.1em 0.5em; background: #eee; border-radius: 3px; font-size: 0.8em; margin-right: 0.25em; }
|
|
27
|
+
.text-end { text-align: right; }
|
|
28
|
+
.refresh-bar { display: flex; gap: 0.5em; align-items: center; justify-content: flex-end; font-size: 0.85em; margin-bottom: 0.5em; }
|
|
29
|
+
.refresh-bar button { font: inherit; background: transparent; text-decoration: none; padding: 0.15em 0.5em; border: 1px solid #ddd; border-radius: 3px; color: #555; cursor: pointer; }
|
|
30
|
+
.refresh-bar button.on { background: #e3f0d6; border-color: #b7d59c; color: #2b5a18; }
|
|
31
|
+
.refresh-bar button:hover { background: #f4f4f4; }
|
|
32
|
+
.refresh-bar button.on:hover { background: #d7e7c5; }
|
|
33
|
+
.chart-global { height: 220px; position: relative; margin: 0.5em 0 1em; }
|
|
34
|
+
.sparkline-cell { padding: 0.2em 0.6em 0.4em !important; border-bottom: 1px solid #eee !important; background: #fafafa; }
|
|
35
|
+
.sparkline-wrap { height: 70px; position: relative; }
|
|
36
|
+
.sparkline-empty { display: flex; align-items: center; justify-content: center; font-size: 0.85em; }
|
|
37
|
+
.chip { font: inherit; padding: 0.1em 0.5em; min-width: 1.6em; border: 1px solid #ccc; background: #fff; border-radius: 3px; cursor: pointer; font-size: 0.85em; color: #555; }
|
|
38
|
+
.chip:hover { background: #f4f4f4; }
|
|
39
|
+
.chip.on { background: #e3f0d6; border-color: #b7d59c; color: #2b5a18; cursor: default; }
|
|
40
|
+
.partition-search { margin: 0.75em 0; display: flex; gap: 0.5em; align-items: center; }
|
|
41
|
+
.partition-search input[type="text"] { font: inherit; padding: 0.25em 0.5em; border: 1px solid #ccc; border-radius: 3px; }
|
|
42
|
+
.partition-search input[type="submit"] { font: inherit; padding: 0.25em 0.75em; border: 1px solid #bbb; background: #f4f4f4; border-radius: 3px; cursor: pointer; }
|
|
43
|
+
.pagination { display: flex; gap: 1em; align-items: center; margin-top: 0.5em; font-size: 0.9em; }
|
|
44
|
+
th a { color: inherit; text-decoration: none; }
|
|
45
|
+
th a:hover { color: #0057b7; }
|
|
46
|
+
th a.sorted { color: #0057b7; }
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<div class="refresh-bar">
|
|
51
|
+
<span class="muted">Auto-refresh:</span>
|
|
52
|
+
<% [ [ "off", 0 ], [ "2s", 2 ], [ "5s", 5 ], [ "15s", 15 ] ].each do |label, seconds| %>
|
|
53
|
+
<button type="button" data-refresh="<%= seconds %>"><%= label %></button>
|
|
54
|
+
<% end %>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<%= yield %>
|
|
58
|
+
|
|
59
|
+
<script>
|
|
60
|
+
(function () {
|
|
61
|
+
const KEY = "dispatch_policy_refresh_seconds";
|
|
62
|
+
let refreshTimer = null;
|
|
63
|
+
|
|
64
|
+
function scheduleRefresh() {
|
|
65
|
+
clearTimeout(refreshTimer);
|
|
66
|
+
const seconds = parseInt(localStorage.getItem(KEY) || "0", 10);
|
|
67
|
+
if (seconds <= 0) return;
|
|
68
|
+
refreshTimer = setTimeout(() => {
|
|
69
|
+
// Turbo.visit fetches + morphs thanks to the turbo-refresh-method
|
|
70
|
+
// meta tag, so the page updates in place without a full reload —
|
|
71
|
+
// scroll position, chart tooltips, form focus stay put.
|
|
72
|
+
if (window.Turbo) {
|
|
73
|
+
Turbo.visit(location.href, { action: "replace" });
|
|
74
|
+
} else {
|
|
75
|
+
location.reload();
|
|
76
|
+
}
|
|
77
|
+
}, seconds * 1000);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function wireRefreshButtons() {
|
|
81
|
+
const current = parseInt(localStorage.getItem(KEY) || "0", 10);
|
|
82
|
+
document.querySelectorAll(".refresh-bar button[data-refresh]").forEach(btn => {
|
|
83
|
+
btn.classList.toggle("on", parseInt(btn.dataset.refresh, 10) === current);
|
|
84
|
+
if (btn.dataset.wired) return;
|
|
85
|
+
btn.dataset.wired = "true";
|
|
86
|
+
btn.addEventListener("click", () => {
|
|
87
|
+
const v = parseInt(btn.dataset.refresh, 10);
|
|
88
|
+
if (v === 0) localStorage.removeItem(KEY);
|
|
89
|
+
else localStorage.setItem(KEY, String(v));
|
|
90
|
+
wireRefreshButtons();
|
|
91
|
+
scheduleRefresh();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Charts get destroyed + rebuilt on each render because Chart.js
|
|
97
|
+
// keeps a registry keyed by canvas element; the morphed canvases
|
|
98
|
+
// are fresh DOM, so previous Chart instances would leak listeners.
|
|
99
|
+
function destroyExistingCharts() {
|
|
100
|
+
if (!window.Chart) return;
|
|
101
|
+
Chart.instances && Object.values(Chart.instances).forEach(c => c.destroy && c.destroy());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Chart.js initialization for canvases with data-chart.
|
|
105
|
+
const BLUE = "#0057b7";
|
|
106
|
+
const BAR_FILL = "rgba(0, 87, 183, 0.18)";
|
|
107
|
+
const TOOLTIP_FMT = {
|
|
108
|
+
titleFont: { size: 11 },
|
|
109
|
+
bodyFont: { size: 11 },
|
|
110
|
+
padding: 6,
|
|
111
|
+
displayColors: false,
|
|
112
|
+
boxPadding: 3
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function parseJSON(s, fallback) {
|
|
116
|
+
try { return JSON.parse(s); } catch (e) { return fallback; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderCharts() {
|
|
120
|
+
if (!window.Chart) return;
|
|
121
|
+
document.querySelectorAll("canvas[data-chart]").forEach(canvas => {
|
|
122
|
+
const kind = canvas.dataset.chart;
|
|
123
|
+
const values = parseJSON(canvas.dataset.values, []);
|
|
124
|
+
const labels = parseJSON(canvas.dataset.labels, []);
|
|
125
|
+
const counts = parseJSON(canvas.dataset.counts, []);
|
|
126
|
+
const label = canvas.dataset.label || "";
|
|
127
|
+
|
|
128
|
+
const datasets = [];
|
|
129
|
+
if (counts.length) {
|
|
130
|
+
datasets.push({
|
|
131
|
+
type: "bar",
|
|
132
|
+
label: "completions/min",
|
|
133
|
+
data: counts,
|
|
134
|
+
backgroundColor: BAR_FILL,
|
|
135
|
+
borderWidth: 0,
|
|
136
|
+
yAxisID: "y1",
|
|
137
|
+
order: 2
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
datasets.push({
|
|
141
|
+
type: "line",
|
|
142
|
+
label: label || "ewma ms",
|
|
143
|
+
data: values,
|
|
144
|
+
borderColor: BLUE,
|
|
145
|
+
backgroundColor: "rgba(0, 87, 183, 0.1)",
|
|
146
|
+
tension: 0.25,
|
|
147
|
+
fill: kind === "line",
|
|
148
|
+
pointRadius: 0,
|
|
149
|
+
pointHoverRadius: 4,
|
|
150
|
+
pointHitRadius: 10,
|
|
151
|
+
borderWidth: kind === "sparkline" ? 1.5 : 2,
|
|
152
|
+
spanGaps: false,
|
|
153
|
+
yAxisID: "y",
|
|
154
|
+
order: 1
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const isSpark = kind === "sparkline";
|
|
158
|
+
const config = {
|
|
159
|
+
data: { labels: labels.length ? labels : values.map((_, i) => i), datasets },
|
|
160
|
+
options: {
|
|
161
|
+
responsive: true, maintainAspectRatio: false,
|
|
162
|
+
interaction: { mode: "index", intersect: false },
|
|
163
|
+
plugins: {
|
|
164
|
+
legend: { display: false },
|
|
165
|
+
tooltip: Object.assign({}, TOOLTIP_FMT, {
|
|
166
|
+
enabled: true,
|
|
167
|
+
callbacks: {
|
|
168
|
+
title: items => items[0].label,
|
|
169
|
+
label: ctx => {
|
|
170
|
+
if (ctx.dataset.yAxisID === "y1") {
|
|
171
|
+
return `${ctx.parsed.y} completions`;
|
|
172
|
+
}
|
|
173
|
+
return ctx.parsed.y != null ? `${ctx.parsed.y} ms` : "no data";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
},
|
|
178
|
+
scales: {
|
|
179
|
+
x: isSpark
|
|
180
|
+
? { display: false, min: 0, max: values.length - 1 }
|
|
181
|
+
: { ticks: { maxTicksLimit: 12, autoSkip: true } },
|
|
182
|
+
y: isSpark
|
|
183
|
+
? { display: false, beginAtZero: true }
|
|
184
|
+
: { beginAtZero: true, ticks: { callback: v => v + "ms" } },
|
|
185
|
+
y1: {
|
|
186
|
+
position: "right",
|
|
187
|
+
display: !isSpark,
|
|
188
|
+
beginAtZero: true,
|
|
189
|
+
grid: { drawOnChartArea: false },
|
|
190
|
+
ticks: { callback: v => v + "/m" }
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
animation: false
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
new Chart(canvas, config);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Watched-partitions manager. Scopes the localStorage list to the
|
|
201
|
+
// current policy (via [data-policy-name]); watch/unwatch clicks
|
|
202
|
+
// rewrite the URL's `watch` param and Turbo.visit into it.
|
|
203
|
+
function policyKey() {
|
|
204
|
+
const el = document.querySelector("[data-policy-name]");
|
|
205
|
+
return el ? `dp_watched:${el.dataset.policyName}` : null;
|
|
206
|
+
}
|
|
207
|
+
function getWatched() {
|
|
208
|
+
const key = policyKey(); if (!key) return [];
|
|
209
|
+
try { return JSON.parse(localStorage.getItem(key) || "[]"); } catch (_) { return []; }
|
|
210
|
+
}
|
|
211
|
+
function setWatched(arr) {
|
|
212
|
+
const key = policyKey(); if (!key) return;
|
|
213
|
+
localStorage.setItem(key, JSON.stringify(arr));
|
|
214
|
+
const url = new URL(location.href);
|
|
215
|
+
if (arr.length) url.searchParams.set("watch", arr.join(","));
|
|
216
|
+
else url.searchParams.delete("watch");
|
|
217
|
+
if (window.Turbo) Turbo.visit(url.toString(), { action: "replace" });
|
|
218
|
+
else location.href = url.toString();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function wireWatch() {
|
|
222
|
+
document.addEventListener("click", (e) => {
|
|
223
|
+
const add = e.target.closest("[data-watch]");
|
|
224
|
+
const rm = e.target.closest("[data-unwatch]");
|
|
225
|
+
if (!add && !rm) return;
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
const arr = getWatched();
|
|
228
|
+
if (add) {
|
|
229
|
+
if (!arr.includes(add.dataset.watch)) arr.push(add.dataset.watch);
|
|
230
|
+
} else {
|
|
231
|
+
const idx = arr.indexOf(rm.dataset.unwatch);
|
|
232
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
233
|
+
}
|
|
234
|
+
setWatched(arr);
|
|
235
|
+
}, { once: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// On first load, if localStorage has a watched list but the URL
|
|
239
|
+
// doesn't, inject it so the page renders the right partitions.
|
|
240
|
+
function syncWatchedIntoUrl() {
|
|
241
|
+
const arr = getWatched();
|
|
242
|
+
if (!arr.length) return;
|
|
243
|
+
const url = new URL(location.href);
|
|
244
|
+
if (url.searchParams.get("watch") === arr.join(",")) return;
|
|
245
|
+
url.searchParams.set("watch", arr.join(","));
|
|
246
|
+
if (window.Turbo) Turbo.visit(url.toString(), { action: "replace" });
|
|
247
|
+
else location.replace(url.toString());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function boot() {
|
|
251
|
+
destroyExistingCharts();
|
|
252
|
+
wireRefreshButtons();
|
|
253
|
+
wireWatch();
|
|
254
|
+
renderCharts();
|
|
255
|
+
scheduleRefresh();
|
|
256
|
+
syncWatchedIntoUrl();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Run on the initial load + whenever Turbo renders (morph or full).
|
|
260
|
+
document.addEventListener("DOMContentLoaded", boot);
|
|
261
|
+
document.addEventListener("turbo:render", boot);
|
|
262
|
+
document.addEventListener("turbo:load", boot);
|
|
263
|
+
})();
|
|
264
|
+
</script>
|
|
265
|
+
</body>
|
|
266
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|