dispatch_policy 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -28
  3. data/MIT-LICENSE +16 -17
  4. data/README.md +452 -388
  5. data/app/assets/images/dispatch_policy/logo-large.svg +9 -0
  6. data/app/assets/images/dispatch_policy/logo-small.svg +7 -0
  7. data/app/assets/javascripts/dispatch_policy/turbo.es2017-umd.min.js +35 -0
  8. data/app/assets/stylesheets/dispatch_policy/application.css +294 -0
  9. data/app/controllers/dispatch_policy/application_controller.rb +45 -1
  10. data/app/controllers/dispatch_policy/assets_controller.rb +31 -0
  11. data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
  12. data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
  13. data/app/controllers/dispatch_policy/policies_controller.rb +94 -267
  14. data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
  15. data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
  16. data/app/models/dispatch_policy/inflight_job.rb +12 -0
  17. data/app/models/dispatch_policy/partition.rb +21 -0
  18. data/app/models/dispatch_policy/staged_job.rb +4 -97
  19. data/app/models/dispatch_policy/tick_sample.rb +11 -0
  20. data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
  21. data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
  22. data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
  23. data/app/views/dispatch_policy/policies/index.html.erb +15 -37
  24. data/app/views/dispatch_policy/policies/show.html.erb +139 -223
  25. data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
  26. data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
  27. data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
  28. data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
  29. data/app/views/layouts/dispatch_policy/application.html.erb +164 -231
  30. data/config/routes.rb +21 -2
  31. data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
  32. data/lib/dispatch_policy/assets.rb +38 -0
  33. data/lib/dispatch_policy/bypass.rb +23 -0
  34. data/lib/dispatch_policy/config.rb +85 -0
  35. data/lib/dispatch_policy/context.rb +50 -0
  36. data/lib/dispatch_policy/cursor_pagination.rb +121 -0
  37. data/lib/dispatch_policy/decision.rb +22 -0
  38. data/lib/dispatch_policy/engine.rb +5 -27
  39. data/lib/dispatch_policy/forwarder.rb +63 -0
  40. data/lib/dispatch_policy/gate.rb +10 -38
  41. data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
  42. data/lib/dispatch_policy/gates/concurrency.rb +45 -26
  43. data/lib/dispatch_policy/gates/throttle.rb +65 -41
  44. data/lib/dispatch_policy/inflight_tracker.rb +174 -0
  45. data/lib/dispatch_policy/job_extension.rb +155 -0
  46. data/lib/dispatch_policy/operator_hints.rb +126 -0
  47. data/lib/dispatch_policy/pipeline.rb +48 -0
  48. data/lib/dispatch_policy/policy.rb +61 -59
  49. data/lib/dispatch_policy/policy_dsl.rb +120 -0
  50. data/lib/dispatch_policy/railtie.rb +35 -0
  51. data/lib/dispatch_policy/registry.rb +46 -0
  52. data/lib/dispatch_policy/repository.rb +723 -0
  53. data/lib/dispatch_policy/serializer.rb +36 -0
  54. data/lib/dispatch_policy/tick.rb +260 -256
  55. data/lib/dispatch_policy/tick_loop.rb +59 -26
  56. data/lib/dispatch_policy/version.rb +1 -1
  57. data/lib/dispatch_policy.rb +72 -52
  58. data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
  59. data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
  60. data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
  61. data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
  62. metadata +134 -42
  63. data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
  64. data/app/models/dispatch_policy/partition_observation.rb +0 -76
  65. data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
  66. data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
  67. data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
  68. data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
  69. data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
  70. data/db/migrate/20260425000001_add_duration_to_partition_observations.rb +0 -8
  71. data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
  72. data/lib/dispatch_policy/dispatch_context.rb +0 -53
  73. data/lib/dispatch_policy/dispatchable.rb +0 -123
  74. data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
  75. data/lib/dispatch_policy/gates/global_cap.rb +0 -26
@@ -1,266 +1,199 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html lang="en">
3
3
  <head>
4
- <title>DispatchPolicy</title>
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
- <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
-
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
- const KEY = "dispatch_policy_refresh_seconds";
62
- let refreshTimer = null;
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 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);
39
+ function getInterval() {
40
+ return parseInt(sessionStorage.getItem(KEY) || "0", 10);
78
41
  }
79
42
 
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
- });
43
+ function setInterval(value) {
44
+ sessionStorage.setItem(KEY, String(value));
45
+ syncControls();
46
+ restart();
94
47
  }
95
48
 
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());
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
- // 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; }
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 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
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
- 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
- });
85
+ function init() {
86
+ bindControls();
87
+ restart();
198
88
  }
199
89
 
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;
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
- function getWatched() {
208
- const key = policyKey(); if (!key) return [];
209
- try { return JSON.parse(localStorage.getItem(key) || "[]"); } catch (_) { return []; }
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
- 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();
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 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);
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
- const idx = arr.indexOf(rm.dataset.unwatch);
232
- if (idx >= 0) arr.splice(idx, 1);
138
+ btn.classList.remove("dp-control-active");
233
139
  }
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());
140
+ });
248
141
  }
249
142
 
250
- function boot() {
251
- destroyExistingCharts();
252
- wireRefreshButtons();
253
- wireWatch();
254
- renderCharts();
255
- scheduleRefresh();
256
- syncWatchedIntoUrl();
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
- // 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);
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: "policies#index"
5
- resources :policies, only: %i[index show], param: :policy_name, constraints: { policy_name: %r{[^/]+} }
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