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,266 +1,199 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
2
|
+
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
<
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>dispatch_policy</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="<%= logo_asset_path(digest: DispatchPolicy::Assets::LOGO_SMALL_DIGEST) %>">
|
|
5
8
|
<%= csrf_meta_tags %>
|
|
9
|
+
<%# Same-URL Turbo.visit (used by the auto-refresh) is treated as a "page %>
|
|
10
|
+
<%# refresh"; with these two meta tags Turbo morphs the body in place and %>
|
|
11
|
+
<%# preserves scroll position. %>
|
|
6
12
|
<meta name="turbo-refresh-method" content="morph">
|
|
7
13
|
<meta name="turbo-refresh-scroll" content="preserve">
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
14
|
+
<%# Apply the stored theme synchronously before the stylesheet renders, %>
|
|
15
|
+
<%# otherwise users with explicit dark mode would see a light-mode flash. %>
|
|
16
|
+
<script>
|
|
17
|
+
(function () {
|
|
18
|
+
try {
|
|
19
|
+
var t = localStorage.getItem("dispatch_policy:theme");
|
|
20
|
+
if (t === "dark" || t === "light") {
|
|
21
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
22
|
+
}
|
|
23
|
+
} catch (e) { /* localStorage unavailable; fall through to auto */ }
|
|
24
|
+
})();
|
|
25
|
+
</script>
|
|
26
|
+
<style><%= DispatchPolicy::Engine.root.join("app/assets/stylesheets/dispatch_policy/application.css").read.html_safe %></style>
|
|
27
|
+
<script src="<%= turbo_asset_path(digest: DispatchPolicy::Assets::TURBO_DIGEST) %>"></script>
|
|
59
28
|
<script>
|
|
60
29
|
(function () {
|
|
61
|
-
|
|
62
|
-
|
|
30
|
+
var KEY = "dispatch_policy:refresh-interval";
|
|
31
|
+
var timer = null;
|
|
32
|
+
// True between turbo:visit and turbo:load|turbo:render. While true,
|
|
33
|
+
// setTimeout reprograms instead of firing — otherwise a slow page
|
|
34
|
+
// (DB churn under load, /partitions with many rows) lets the
|
|
35
|
+
// setTimeout fire while the previous Turbo visit is still in
|
|
36
|
+
// flight, stacking requests on top of each other.
|
|
37
|
+
var visiting = false;
|
|
63
38
|
|
|
64
|
-
function
|
|
65
|
-
|
|
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);
|
|
39
|
+
function getInterval() {
|
|
40
|
+
return parseInt(sessionStorage.getItem(KEY) || "0", 10);
|
|
78
41
|
}
|
|
79
42
|
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
});
|
|
43
|
+
function setInterval(value) {
|
|
44
|
+
sessionStorage.setItem(KEY, String(value));
|
|
45
|
+
syncControls();
|
|
46
|
+
restart();
|
|
94
47
|
}
|
|
95
48
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
49
|
+
function restart() {
|
|
50
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
51
|
+
var seconds = getInterval();
|
|
52
|
+
if (seconds > 0) {
|
|
53
|
+
timer = setTimeout(function () {
|
|
54
|
+
if (visiting) { restart(); return; }
|
|
55
|
+
if (typeof Turbo !== "undefined") {
|
|
56
|
+
Turbo.visit(window.location.href, { action: "replace" });
|
|
57
|
+
} else {
|
|
58
|
+
window.location.assign(window.location.href);
|
|
59
|
+
}
|
|
60
|
+
}, seconds * 1000);
|
|
61
|
+
}
|
|
102
62
|
}
|
|
103
63
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
displayColors: false,
|
|
112
|
-
boxPadding: 3
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
function parseJSON(s, fallback) {
|
|
116
|
-
try { return JSON.parse(s); } catch (e) { return fallback; }
|
|
64
|
+
function syncControls() {
|
|
65
|
+
var current = getInterval();
|
|
66
|
+
document.querySelectorAll("[data-dp-refresh]").forEach(function (btn) {
|
|
67
|
+
var v = parseInt(btn.getAttribute("data-dp-refresh"), 10);
|
|
68
|
+
if (v === current) btn.classList.add("dp-refresh-active");
|
|
69
|
+
else btn.classList.remove("dp-refresh-active");
|
|
70
|
+
});
|
|
117
71
|
}
|
|
118
72
|
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
73
|
+
function bindControls() {
|
|
74
|
+
document.querySelectorAll("[data-dp-refresh]").forEach(function (btn) {
|
|
75
|
+
if (btn.dataset.bound) return;
|
|
76
|
+
btn.dataset.bound = "1";
|
|
77
|
+
btn.addEventListener("click", function (e) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
setInterval(parseInt(btn.getAttribute("data-dp-refresh"), 10));
|
|
138
80
|
});
|
|
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
81
|
});
|
|
82
|
+
syncControls();
|
|
83
|
+
}
|
|
156
84
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
});
|
|
85
|
+
function init() {
|
|
86
|
+
bindControls();
|
|
87
|
+
restart();
|
|
198
88
|
}
|
|
199
89
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
90
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
91
|
+
document.addEventListener("turbo:load", init);
|
|
92
|
+
|
|
93
|
+
document.addEventListener("turbo:visit", function () { visiting = true; });
|
|
94
|
+
document.addEventListener("turbo:load", function () { visiting = false; });
|
|
95
|
+
document.addEventListener("turbo:render", function () { visiting = false; });
|
|
96
|
+
|
|
97
|
+
// Pause refresh when the tab is hidden (saves DB load); resume when visible.
|
|
98
|
+
document.addEventListener("visibilitychange", function () {
|
|
99
|
+
if (document.hidden) {
|
|
100
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
101
|
+
} else {
|
|
102
|
+
restart();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
})();
|
|
106
|
+
|
|
107
|
+
// Theme controls (auto / light / dark). Persists to localStorage so
|
|
108
|
+
// it survives across sessions; the early script in <head> applies
|
|
109
|
+
// the stored theme before paint to avoid FOUC.
|
|
110
|
+
(function () {
|
|
111
|
+
var KEY = "dispatch_policy:theme";
|
|
112
|
+
|
|
113
|
+
function getTheme() {
|
|
114
|
+
try { return localStorage.getItem(KEY) || "auto"; }
|
|
115
|
+
catch (e) { return "auto"; }
|
|
206
116
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
117
|
+
|
|
118
|
+
function applyTheme(theme) {
|
|
119
|
+
if (theme === "light" || theme === "dark") {
|
|
120
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
121
|
+
} else {
|
|
122
|
+
document.documentElement.removeAttribute("data-theme");
|
|
123
|
+
}
|
|
210
124
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
localStorage.setItem(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
else url.searchParams.delete("watch");
|
|
217
|
-
if (window.Turbo) Turbo.visit(url.toString(), { action: "replace" });
|
|
218
|
-
else location.href = url.toString();
|
|
125
|
+
|
|
126
|
+
function setTheme(theme) {
|
|
127
|
+
try { localStorage.setItem(KEY, theme); } catch (e) { /* ignore */ }
|
|
128
|
+
applyTheme(theme);
|
|
129
|
+
syncControls();
|
|
219
130
|
}
|
|
220
131
|
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
e.preventDefault();
|
|
227
|
-
const arr = getWatched();
|
|
228
|
-
if (add) {
|
|
229
|
-
if (!arr.includes(add.dataset.watch)) arr.push(add.dataset.watch);
|
|
132
|
+
function syncControls() {
|
|
133
|
+
var current = getTheme();
|
|
134
|
+
document.querySelectorAll("[data-dp-theme]").forEach(function (btn) {
|
|
135
|
+
if (btn.getAttribute("data-dp-theme") === current) {
|
|
136
|
+
btn.classList.add("dp-control-active");
|
|
230
137
|
} else {
|
|
231
|
-
|
|
232
|
-
if (idx >= 0) arr.splice(idx, 1);
|
|
138
|
+
btn.classList.remove("dp-control-active");
|
|
233
139
|
}
|
|
234
|
-
|
|
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());
|
|
140
|
+
});
|
|
248
141
|
}
|
|
249
142
|
|
|
250
|
-
function
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
143
|
+
function bindControls() {
|
|
144
|
+
document.querySelectorAll("[data-dp-theme]").forEach(function (btn) {
|
|
145
|
+
if (btn.dataset.bound) return;
|
|
146
|
+
btn.dataset.bound = "1";
|
|
147
|
+
btn.addEventListener("click", function (e) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
setTheme(btn.getAttribute("data-dp-theme"));
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
syncControls();
|
|
257
153
|
}
|
|
258
154
|
|
|
259
|
-
|
|
260
|
-
document.addEventListener("
|
|
261
|
-
document.addEventListener("turbo:render", boot);
|
|
262
|
-
document.addEventListener("turbo:load", boot);
|
|
155
|
+
document.addEventListener("DOMContentLoaded", bindControls);
|
|
156
|
+
document.addEventListener("turbo:load", bindControls);
|
|
263
157
|
})();
|
|
264
158
|
</script>
|
|
159
|
+
</head>
|
|
160
|
+
<body>
|
|
161
|
+
<header class="dp-header">
|
|
162
|
+
<div class="dp-brand">
|
|
163
|
+
<%= link_to root_path, class: "dp-logo" do %>
|
|
164
|
+
<%= DispatchPolicy::Assets::LOGO_LARGE_BODY.html_safe %>
|
|
165
|
+
<span class="dp-logo-text">dispatch<span class="dp-logo-sep">_</span>policy</span>
|
|
166
|
+
<% end %>
|
|
167
|
+
</div>
|
|
168
|
+
<nav class="dp-nav">
|
|
169
|
+
<%= link_to "Dashboard", root_path %>
|
|
170
|
+
<%= link_to "Policies", policies_path %>
|
|
171
|
+
<%= link_to "Partitions", partitions_path %>
|
|
172
|
+
</nav>
|
|
173
|
+
<div class="dp-controls">
|
|
174
|
+
<div class="dp-refresh">
|
|
175
|
+
<span class="dp-refresh-label">Auto-refresh</span>
|
|
176
|
+
<button type="button" class="dp-refresh-btn" data-dp-refresh="0">off</button>
|
|
177
|
+
<button type="button" class="dp-refresh-btn" data-dp-refresh="2">2s</button>
|
|
178
|
+
<button type="button" class="dp-refresh-btn" data-dp-refresh="5">5s</button>
|
|
179
|
+
<button type="button" class="dp-refresh-btn" data-dp-refresh="10">10s</button>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="dp-control">
|
|
182
|
+
<span class="dp-control-label">Theme</span>
|
|
183
|
+
<button type="button" class="dp-control-btn" data-dp-theme="auto">auto</button>
|
|
184
|
+
<button type="button" class="dp-control-btn" data-dp-theme="light">light</button>
|
|
185
|
+
<button type="button" class="dp-control-btn" data-dp-theme="dark">dark</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</header>
|
|
189
|
+
<% if flash[:notice] %><div class="dp-flash dp-flash-ok"><%= flash[:notice] %></div><% end %>
|
|
190
|
+
<% if flash[:alert] %><div class="dp-flash dp-flash-err"><%= flash[:alert] %></div><% end %>
|
|
191
|
+
<main class="dp-main">
|
|
192
|
+
<%= yield %>
|
|
193
|
+
</main>
|
|
194
|
+
<footer class="dp-footer">
|
|
195
|
+
<span>dispatch_policy v<%= DispatchPolicy::VERSION %></span>
|
|
196
|
+
<span>now: <%= Time.current.utc.strftime("%Y-%m-%d %H:%M:%S UTC") %></span>
|
|
197
|
+
</footer>
|
|
265
198
|
</body>
|
|
266
199
|
</html>
|
data/config/routes.rb
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
DispatchPolicy::Engine.routes.draw do
|
|
4
|
-
root to: "
|
|
5
|
-
|
|
4
|
+
root to: "dashboard#index"
|
|
5
|
+
|
|
6
|
+
resources :policies, only: %i[index show], param: :name, constraints: { name: %r{[^/]+} } do
|
|
7
|
+
member do
|
|
8
|
+
post :pause
|
|
9
|
+
post :resume
|
|
10
|
+
post :drain
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
resources :partitions, only: %i[index show] do
|
|
15
|
+
member do
|
|
16
|
+
post :drain
|
|
17
|
+
post :admit
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
resources :staged_jobs, only: %i[show]
|
|
22
|
+
|
|
23
|
+
get "assets/turbo-:digest.js", to: "assets#turbo", as: :turbo_asset
|
|
24
|
+
get "assets/logo-:digest.svg", to: "assets#logo", as: :logo_asset
|
|
6
25
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
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 :policy_name, null: false
|
|
7
|
+
t.string :partition_key, null: false
|
|
8
|
+
t.string :queue_name
|
|
9
|
+
t.string :job_class, null: false
|
|
10
|
+
t.jsonb :job_data, null: false
|
|
11
|
+
t.datetime :scheduled_at
|
|
12
|
+
t.integer :priority, default: 0, null: false
|
|
13
|
+
t.datetime :enqueued_at, null: false, default: -> { "now()" }
|
|
14
|
+
t.jsonb :context, null: false, default: {}
|
|
15
|
+
end
|
|
16
|
+
add_index :dispatch_policy_staged_jobs,
|
|
17
|
+
[:policy_name, :partition_key, :scheduled_at, :id],
|
|
18
|
+
name: "idx_dp_staged_admission",
|
|
19
|
+
order: { scheduled_at: "ASC NULLS FIRST", id: :asc }
|
|
20
|
+
add_index :dispatch_policy_staged_jobs, :enqueued_at,
|
|
21
|
+
name: "idx_dp_staged_enqueued_at"
|
|
22
|
+
|
|
23
|
+
create_table :dispatch_policy_partitions do |t|
|
|
24
|
+
t.string :policy_name, null: false
|
|
25
|
+
t.string :partition_key, null: false
|
|
26
|
+
t.string :queue_name
|
|
27
|
+
t.string :shard, null: false, default: "default"
|
|
28
|
+
t.string :status, null: false, default: "active"
|
|
29
|
+
t.integer :pending_count, null: false, default: 0
|
|
30
|
+
t.bigint :total_admitted, null: false, default: 0
|
|
31
|
+
t.jsonb :context, null: false, default: {}
|
|
32
|
+
t.datetime :context_updated_at
|
|
33
|
+
t.datetime :last_enqueued_at
|
|
34
|
+
t.datetime :last_checked_at
|
|
35
|
+
t.datetime :last_admit_at
|
|
36
|
+
t.datetime :next_eligible_at
|
|
37
|
+
t.jsonb :gate_state, null: false, default: {}
|
|
38
|
+
t.float :decayed_admits, null: false, default: 0.0
|
|
39
|
+
t.datetime :decayed_admits_at
|
|
40
|
+
t.timestamps
|
|
41
|
+
end
|
|
42
|
+
add_index :dispatch_policy_partitions,
|
|
43
|
+
[:policy_name, :partition_key],
|
|
44
|
+
unique: true,
|
|
45
|
+
name: "idx_dp_partitions_lookup"
|
|
46
|
+
add_index :dispatch_policy_partitions,
|
|
47
|
+
[:policy_name, :shard, :status, :next_eligible_at, :last_checked_at],
|
|
48
|
+
name: "idx_dp_partitions_tick_order",
|
|
49
|
+
order: { next_eligible_at: "ASC NULLS FIRST", last_checked_at: "ASC NULLS FIRST" }
|
|
50
|
+
|
|
51
|
+
create_table :dispatch_policy_inflight_jobs do |t|
|
|
52
|
+
t.string :policy_name, null: false
|
|
53
|
+
t.string :partition_key, null: false
|
|
54
|
+
t.string :active_job_id, null: false
|
|
55
|
+
t.datetime :admitted_at, null: false, default: -> { "now()" }
|
|
56
|
+
t.datetime :heartbeat_at, null: false, default: -> { "now()" }
|
|
57
|
+
end
|
|
58
|
+
add_index :dispatch_policy_inflight_jobs, :active_job_id, unique: true,
|
|
59
|
+
name: "idx_dp_inflight_active_job_id"
|
|
60
|
+
add_index :dispatch_policy_inflight_jobs, [:policy_name, :partition_key],
|
|
61
|
+
name: "idx_dp_inflight_partition"
|
|
62
|
+
add_index :dispatch_policy_inflight_jobs, :heartbeat_at,
|
|
63
|
+
name: "idx_dp_inflight_heartbeat"
|
|
64
|
+
|
|
65
|
+
create_table :dispatch_policy_tick_samples do |t|
|
|
66
|
+
t.string :policy_name, null: false
|
|
67
|
+
t.datetime :sampled_at, null: false, default: -> { "now()" }
|
|
68
|
+
t.integer :duration_ms, null: false, default: 0
|
|
69
|
+
t.integer :partitions_seen, null: false, default: 0
|
|
70
|
+
t.integer :partitions_admitted, null: false, default: 0
|
|
71
|
+
t.integer :partitions_denied, null: false, default: 0
|
|
72
|
+
t.integer :jobs_admitted, null: false, default: 0
|
|
73
|
+
t.integer :forward_failures, null: false, default: 0
|
|
74
|
+
t.integer :pending_total, null: false, default: 0
|
|
75
|
+
t.integer :inflight_total, null: false, default: 0
|
|
76
|
+
t.jsonb :denied_reasons, null: false, default: {}
|
|
77
|
+
end
|
|
78
|
+
add_index :dispatch_policy_tick_samples, [:policy_name, :sampled_at],
|
|
79
|
+
name: "idx_dp_tick_samples_lookup",
|
|
80
|
+
order: { sampled_at: :desc }
|
|
81
|
+
add_index :dispatch_policy_tick_samples, :sampled_at,
|
|
82
|
+
name: "idx_dp_tick_samples_sweep"
|
|
83
|
+
|
|
84
|
+
# adaptive_concurrency stats: one row per (policy_name, partition_key)
|
|
85
|
+
# for partitions whose policy declares an :adaptive_concurrency gate.
|
|
86
|
+
# Holds the AIMD-tuned current_max plus the EWMA queue-lag signal it
|
|
87
|
+
# adapts on. Populated by Repository.adaptive_seed! on first admission
|
|
88
|
+
# and updated by Repository.adaptive_record! after each perform.
|
|
89
|
+
create_table :dispatch_policy_adaptive_concurrency_stats do |t|
|
|
90
|
+
t.string :policy_name, null: false
|
|
91
|
+
t.string :partition_key, null: false
|
|
92
|
+
t.integer :current_max, null: false
|
|
93
|
+
t.float :ewma_latency_ms, null: false, default: 0.0
|
|
94
|
+
t.integer :sample_count, null: false, default: 0
|
|
95
|
+
t.datetime :last_observed_at
|
|
96
|
+
t.timestamps
|
|
97
|
+
end
|
|
98
|
+
add_index :dispatch_policy_adaptive_concurrency_stats,
|
|
99
|
+
[:policy_name, :partition_key],
|
|
100
|
+
unique: true,
|
|
101
|
+
name: "idx_dp_adaptive_concurrency_lookup"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module DispatchPolicy
|
|
7
|
+
# Vendored static assets served by AssetsController. Bodies are read
|
|
8
|
+
# once at boot and the digest is embedded in the URL so the response
|
|
9
|
+
# can be marked `Cache-Control: immutable` — bumping the vendored file
|
|
10
|
+
# produces a new digest and the host's browsers refetch automatically.
|
|
11
|
+
#
|
|
12
|
+
# To upgrade Turbo (current: 8.0.4), overwrite the file from the same
|
|
13
|
+
# CDN/version pair the rest of the Hotwire ecosystem uses:
|
|
14
|
+
#
|
|
15
|
+
# curl -fsSL https://cdn.jsdelivr.net/npm/@hotwired/turbo@<VERSION>/dist/turbo.es2017-umd.min.js \
|
|
16
|
+
# -o app/assets/javascripts/dispatch_policy/turbo.es2017-umd.min.js
|
|
17
|
+
#
|
|
18
|
+
# No other code change is required — TURBO_DIGEST is content-addressed.
|
|
19
|
+
module Assets
|
|
20
|
+
JS_ROOT = Pathname.new(File.expand_path("../../app/assets/javascripts/dispatch_policy", __dir__))
|
|
21
|
+
IMAGE_ROOT = Pathname.new(File.expand_path("../../app/assets/images/dispatch_policy", __dir__))
|
|
22
|
+
|
|
23
|
+
TURBO_BODY = JS_ROOT.join("turbo.es2017-umd.min.js").read.freeze
|
|
24
|
+
TURBO_DIGEST = Digest::SHA1.hexdigest(TURBO_BODY)[0, 12].freeze
|
|
25
|
+
|
|
26
|
+
# The "large" mark (≥ 48px) is used in the admin header — three
|
|
27
|
+
# chevrons with the rightmost one carrying state color via
|
|
28
|
+
# `currentColor`. The "small" mark (≤ 32px) is used as the SVG
|
|
29
|
+
# favicon, where the lanes get lost at downsampling. Both are
|
|
30
|
+
# themable: wrapping with `style="color: …"` swaps the state color
|
|
31
|
+
# (ok/info/neutral/warn/error).
|
|
32
|
+
LOGO_LARGE_BODY = IMAGE_ROOT.join("logo-large.svg").read.freeze
|
|
33
|
+
LOGO_LARGE_DIGEST = Digest::SHA1.hexdigest(LOGO_LARGE_BODY)[0, 12].freeze
|
|
34
|
+
|
|
35
|
+
LOGO_SMALL_BODY = IMAGE_ROOT.join("logo-small.svg").read.freeze
|
|
36
|
+
LOGO_SMALL_DIGEST = Digest::SHA1.hexdigest(LOGO_SMALL_BODY)[0, 12].freeze
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
# Thread-local guard. When active, ActiveJob#enqueue calls within the block
|
|
5
|
+
# bypass the dispatch_policy around_enqueue and reach the real adapter.
|
|
6
|
+
module Bypass
|
|
7
|
+
KEY = :__dispatch_policy_bypass__
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def with
|
|
12
|
+
previous = Thread.current[KEY]
|
|
13
|
+
Thread.current[KEY] = true
|
|
14
|
+
yield
|
|
15
|
+
ensure
|
|
16
|
+
Thread.current[KEY] = previous
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def active?
|
|
20
|
+
Thread.current[KEY] == true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|