dispatch_policy 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/MIT-LICENSE +16 -17
- data/README.md +449 -288
- data/app/assets/stylesheets/dispatch_policy/application.css +157 -0
- data/app/controllers/dispatch_policy/application_controller.rb +45 -1
- data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
- data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
- data/app/controllers/dispatch_policy/policies_controller.rb +94 -241
- data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
- data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
- data/app/models/dispatch_policy/inflight_job.rb +12 -0
- data/app/models/dispatch_policy/partition.rb +21 -0
- data/app/models/dispatch_policy/staged_job.rb +4 -97
- data/app/models/dispatch_policy/tick_sample.rb +11 -0
- data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
- data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
- data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
- data/app/views/dispatch_policy/policies/index.html.erb +15 -37
- data/app/views/dispatch_policy/policies/show.html.erb +140 -216
- data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
- data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
- data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
- data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
- data/app/views/layouts/dispatch_policy/application.html.erb +95 -238
- data/config/routes.rb +18 -2
- data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
- data/lib/dispatch_policy/bypass.rb +23 -0
- data/lib/dispatch_policy/config.rb +85 -0
- data/lib/dispatch_policy/context.rb +50 -0
- data/lib/dispatch_policy/cursor_pagination.rb +121 -0
- data/lib/dispatch_policy/decision.rb +22 -0
- data/lib/dispatch_policy/engine.rb +4 -27
- data/lib/dispatch_policy/forwarder.rb +63 -0
- data/lib/dispatch_policy/gate.rb +10 -38
- data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
- data/lib/dispatch_policy/gates/concurrency.rb +45 -26
- data/lib/dispatch_policy/gates/throttle.rb +65 -37
- data/lib/dispatch_policy/inflight_tracker.rb +174 -0
- data/lib/dispatch_policy/job_extension.rb +155 -0
- data/lib/dispatch_policy/operator_hints.rb +126 -0
- data/lib/dispatch_policy/pipeline.rb +48 -0
- data/lib/dispatch_policy/policy.rb +62 -47
- data/lib/dispatch_policy/policy_dsl.rb +120 -0
- data/lib/dispatch_policy/railtie.rb +35 -0
- data/lib/dispatch_policy/registry.rb +46 -0
- data/lib/dispatch_policy/repository.rb +723 -0
- data/lib/dispatch_policy/serializer.rb +36 -0
- data/lib/dispatch_policy/tick.rb +263 -172
- data/lib/dispatch_policy/tick_loop.rb +59 -26
- data/lib/dispatch_policy/version.rb +1 -1
- data/lib/dispatch_policy.rb +71 -46
- data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
- data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
- data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
- data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
- metadata +101 -43
- data/CHANGELOG.md +0 -12
- data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
- data/app/models/dispatch_policy/partition_observation.rb +0 -49
- data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
- data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
- data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
- data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
- data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
- data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
- data/lib/dispatch_policy/dispatch_context.rb +0 -53
- data/lib/dispatch_policy/dispatchable.rb +0 -120
- data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
- data/lib/dispatch_policy/gates/global_cap.rb +0 -26
- data/lib/dispatch_policy/install_generator.rb +0 -23
|
@@ -1,266 +1,123 @@
|
|
|
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>
|
|
5
7
|
<%= csrf_meta_tags %>
|
|
8
|
+
<%# Same-URL Turbo.visit (used by the auto-refresh) is treated as a "page %>
|
|
9
|
+
<%# refresh"; with these two meta tags Turbo morphs the body in place and %>
|
|
10
|
+
<%# preserves scroll position. %>
|
|
6
11
|
<meta name="turbo-refresh-method" content="morph">
|
|
7
12
|
<meta name="turbo-refresh-scroll" content="preserve">
|
|
8
|
-
<
|
|
9
|
-
<script src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.
|
|
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
|
-
|
|
13
|
+
<style><%= DispatchPolicy::Engine.root.join("app/assets/stylesheets/dispatch_policy/application.css").read.html_safe %></style>
|
|
14
|
+
<script src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-umd.min.js"></script>
|
|
59
15
|
<script>
|
|
60
16
|
(function () {
|
|
61
|
-
|
|
62
|
-
|
|
17
|
+
var KEY = "dispatch_policy:refresh-interval";
|
|
18
|
+
var timer = null;
|
|
19
|
+
// True between turbo:visit and turbo:load|turbo:render. While true,
|
|
20
|
+
// setTimeout reprograms instead of firing — otherwise a slow page
|
|
21
|
+
// (DB churn under load, /partitions with many rows) lets the
|
|
22
|
+
// setTimeout fire while the previous Turbo visit is still in
|
|
23
|
+
// flight, stacking requests on top of each other.
|
|
24
|
+
var visiting = false;
|
|
63
25
|
|
|
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);
|
|
26
|
+
function getInterval() {
|
|
27
|
+
return parseInt(sessionStorage.getItem(KEY) || "0", 10);
|
|
78
28
|
}
|
|
79
29
|
|
|
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
|
-
});
|
|
30
|
+
function setInterval(value) {
|
|
31
|
+
sessionStorage.setItem(KEY, String(value));
|
|
32
|
+
syncControls();
|
|
33
|
+
restart();
|
|
94
34
|
}
|
|
95
35
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
36
|
+
function restart() {
|
|
37
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
38
|
+
var seconds = getInterval();
|
|
39
|
+
if (seconds > 0) {
|
|
40
|
+
timer = setTimeout(function () {
|
|
41
|
+
if (visiting) { restart(); return; }
|
|
42
|
+
if (typeof Turbo !== "undefined") {
|
|
43
|
+
Turbo.visit(window.location.href, { action: "replace" });
|
|
44
|
+
} else {
|
|
45
|
+
window.location.assign(window.location.href);
|
|
46
|
+
}
|
|
47
|
+
}, seconds * 1000);
|
|
48
|
+
}
|
|
102
49
|
}
|
|
103
50
|
|
|
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; }
|
|
51
|
+
function syncControls() {
|
|
52
|
+
var current = getInterval();
|
|
53
|
+
document.querySelectorAll("[data-dp-refresh]").forEach(function (btn) {
|
|
54
|
+
var v = parseInt(btn.getAttribute("data-dp-refresh"), 10);
|
|
55
|
+
if (v === current) btn.classList.add("dp-refresh-active");
|
|
56
|
+
else btn.classList.remove("dp-refresh-active");
|
|
57
|
+
});
|
|
117
58
|
}
|
|
118
59
|
|
|
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
|
|
60
|
+
function bindControls() {
|
|
61
|
+
document.querySelectorAll("[data-dp-refresh]").forEach(function (btn) {
|
|
62
|
+
if (btn.dataset.bound) return;
|
|
63
|
+
btn.dataset.bound = "1";
|
|
64
|
+
btn.addEventListener("click", function (e) {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
setInterval(parseInt(btn.getAttribute("data-dp-refresh"), 10));
|
|
138
67
|
});
|
|
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
68
|
});
|
|
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
|
-
});
|
|
69
|
+
syncControls();
|
|
198
70
|
}
|
|
199
71
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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();
|
|
72
|
+
function init() {
|
|
73
|
+
bindControls();
|
|
74
|
+
restart();
|
|
219
75
|
}
|
|
220
76
|
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
}
|
|
77
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
78
|
+
document.addEventListener("turbo:load", init);
|
|
237
79
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
function
|
|
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
|
-
}
|
|
80
|
+
document.addEventListener("turbo:visit", function () { visiting = true; });
|
|
81
|
+
document.addEventListener("turbo:load", function () { visiting = false; });
|
|
82
|
+
document.addEventListener("turbo:render", function () { visiting = false; });
|
|
249
83
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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);
|
|
84
|
+
// Pause refresh when the tab is hidden (saves DB load); resume when visible.
|
|
85
|
+
document.addEventListener("visibilitychange", function () {
|
|
86
|
+
if (document.hidden) {
|
|
87
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
88
|
+
} else {
|
|
89
|
+
restart();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
263
92
|
})();
|
|
264
93
|
</script>
|
|
94
|
+
</head>
|
|
95
|
+
<body>
|
|
96
|
+
<header class="dp-header">
|
|
97
|
+
<div class="dp-brand">
|
|
98
|
+
<%= link_to "dispatch_policy", root_path, class: "dp-logo" %>
|
|
99
|
+
</div>
|
|
100
|
+
<nav class="dp-nav">
|
|
101
|
+
<%= link_to "Dashboard", root_path %>
|
|
102
|
+
<%= link_to "Policies", policies_path %>
|
|
103
|
+
<%= link_to "Partitions", partitions_path %>
|
|
104
|
+
</nav>
|
|
105
|
+
<div class="dp-refresh">
|
|
106
|
+
<span class="dp-refresh-label">Auto-refresh</span>
|
|
107
|
+
<button type="button" class="dp-refresh-btn" data-dp-refresh="0">off</button>
|
|
108
|
+
<button type="button" class="dp-refresh-btn" data-dp-refresh="2">2s</button>
|
|
109
|
+
<button type="button" class="dp-refresh-btn" data-dp-refresh="5">5s</button>
|
|
110
|
+
<button type="button" class="dp-refresh-btn" data-dp-refresh="10">10s</button>
|
|
111
|
+
</div>
|
|
112
|
+
</header>
|
|
113
|
+
<% if flash[:notice] %><div class="dp-flash dp-flash-ok"><%= flash[:notice] %></div><% end %>
|
|
114
|
+
<% if flash[:alert] %><div class="dp-flash dp-flash-err"><%= flash[:alert] %></div><% end %>
|
|
115
|
+
<main class="dp-main">
|
|
116
|
+
<%= yield %>
|
|
117
|
+
</main>
|
|
118
|
+
<footer class="dp-footer">
|
|
119
|
+
<span>dispatch_policy v<%= DispatchPolicy::VERSION %></span>
|
|
120
|
+
<span>now: <%= Time.current.utc.strftime("%Y-%m-%d %H:%M:%S UTC") %></span>
|
|
121
|
+
</footer>
|
|
265
122
|
</body>
|
|
266
123
|
</html>
|
data/config/routes.rb
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
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]
|
|
6
22
|
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,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
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
class Config
|
|
5
|
+
attr_accessor :enabled,
|
|
6
|
+
:tick_max_duration,
|
|
7
|
+
:partition_batch_size,
|
|
8
|
+
:admission_batch_size,
|
|
9
|
+
:idle_pause,
|
|
10
|
+
:busy_pause,
|
|
11
|
+
:partition_inactive_after,
|
|
12
|
+
:inflight_stale_after,
|
|
13
|
+
:inflight_heartbeat_interval,
|
|
14
|
+
:real_adapter,
|
|
15
|
+
:logger,
|
|
16
|
+
:clock,
|
|
17
|
+
:sweep_every_ticks,
|
|
18
|
+
:metrics_retention,
|
|
19
|
+
:database_role,
|
|
20
|
+
:fairness_half_life_seconds,
|
|
21
|
+
:tick_admission_budget,
|
|
22
|
+
:adapter_throughput_target
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
# Master switch. When false, the around_enqueue and the BulkEnqueue
|
|
26
|
+
# patch pass through to the real adapter without staging — all of
|
|
27
|
+
# the gem's machinery becomes a no-op for new perform_later calls.
|
|
28
|
+
# The TickLoop also exits early. Used during cutovers to drain
|
|
29
|
+
# the staging table without taking traffic offline.
|
|
30
|
+
@enabled = true
|
|
31
|
+
@tick_max_duration = 25
|
|
32
|
+
@partition_batch_size = 50
|
|
33
|
+
@admission_batch_size = 100
|
|
34
|
+
@idle_pause = 0.5
|
|
35
|
+
# Sleep between iterations when the previous tick admitted > 0
|
|
36
|
+
# jobs. 0 (default) preserves the original "busy = no pause"
|
|
37
|
+
# behavior. Set to a small value (e.g. 0.02) to back off the DB
|
|
38
|
+
# when several TickLoops compete for connections; the per-loop
|
|
39
|
+
# throughput ceiling becomes admission_batch_size / busy_pause.
|
|
40
|
+
@busy_pause = 0.0
|
|
41
|
+
@partition_inactive_after = 24 * 60 * 60
|
|
42
|
+
@inflight_stale_after = 5 * 60
|
|
43
|
+
@inflight_heartbeat_interval = 30
|
|
44
|
+
@real_adapter = nil
|
|
45
|
+
@logger = nil
|
|
46
|
+
@clock = -> { Time.now.utc }
|
|
47
|
+
@sweep_every_ticks = 50
|
|
48
|
+
@metrics_retention = 24 * 60 * 60
|
|
49
|
+
# AR role for the admission TX. nil = default connection. Set to
|
|
50
|
+
# e.g. :queue when the host runs solid_queue on a separate DB.
|
|
51
|
+
@database_role = nil
|
|
52
|
+
# Fairness: the half-life of decayed_admits (per-partition EWMA).
|
|
53
|
+
# 60s means a partition's "recent activity" weight halves every
|
|
54
|
+
# 60s of idleness. Tick reorders claimed partitions by lowest
|
|
55
|
+
# decayed_admits first; under-admitted ones get first crack.
|
|
56
|
+
@fairness_half_life_seconds = 60
|
|
57
|
+
# Optional global cap on admissions per tick. nil = no cap; each
|
|
58
|
+
# partition uses admission_batch_size as its ceiling. When set,
|
|
59
|
+
# fair_share = ceil(cap / partitions_seen) is the per-partition
|
|
60
|
+
# ceiling, with redistribution of leftover budget after pass-1.
|
|
61
|
+
@tick_admission_budget = nil
|
|
62
|
+
# Operator-supplied "ceiling" of the underlying adapter, in jobs
|
|
63
|
+
# per second. The dashboard renders the live admit rate as a
|
|
64
|
+
# percentage of this and fires a hint when we're closing on it.
|
|
65
|
+
# nil = no ceiling reference (just shows the absolute rate).
|
|
66
|
+
# Measured locally against good_job: ~3500 jobs/sec per worker.
|
|
67
|
+
@adapter_throughput_target = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def now
|
|
71
|
+
@clock.call
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def logger
|
|
75
|
+
@logger || (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || default_logger
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def default_logger
|
|
81
|
+
require "logger"
|
|
82
|
+
@default_logger ||= Logger.new($stdout, level: Logger::INFO)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DispatchPolicy
|
|
4
|
+
class Context
|
|
5
|
+
def self.wrap(value)
|
|
6
|
+
case value
|
|
7
|
+
when Context then value
|
|
8
|
+
when Hash then new(value)
|
|
9
|
+
when nil then new({})
|
|
10
|
+
else
|
|
11
|
+
raise InvalidPolicy, "context must be a Hash, got #{value.class}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :data
|
|
16
|
+
|
|
17
|
+
def initialize(hash)
|
|
18
|
+
@data = deep_stringify(hash).freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def [](key)
|
|
22
|
+
@data[key.to_s]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
@data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_jsonb
|
|
30
|
+
@data
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fetch(key, *args, &block)
|
|
34
|
+
@data.fetch(key.to_s, *args, &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def deep_stringify(value)
|
|
40
|
+
case value
|
|
41
|
+
when Hash
|
|
42
|
+
value.each_with_object({}) { |(k, v), m| m[k.to_s] = deep_stringify(v) }
|
|
43
|
+
when Array
|
|
44
|
+
value.map { |v| deep_stringify(v) }
|
|
45
|
+
else
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|