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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +16 -17
  3. data/README.md +449 -288
  4. data/app/assets/stylesheets/dispatch_policy/application.css +157 -0
  5. data/app/controllers/dispatch_policy/application_controller.rb +45 -1
  6. data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
  7. data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
  8. data/app/controllers/dispatch_policy/policies_controller.rb +94 -241
  9. data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
  10. data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
  11. data/app/models/dispatch_policy/inflight_job.rb +12 -0
  12. data/app/models/dispatch_policy/partition.rb +21 -0
  13. data/app/models/dispatch_policy/staged_job.rb +4 -97
  14. data/app/models/dispatch_policy/tick_sample.rb +11 -0
  15. data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
  16. data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
  17. data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
  18. data/app/views/dispatch_policy/policies/index.html.erb +15 -37
  19. data/app/views/dispatch_policy/policies/show.html.erb +140 -216
  20. data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
  21. data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
  22. data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
  23. data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
  24. data/app/views/layouts/dispatch_policy/application.html.erb +95 -238
  25. data/config/routes.rb +18 -2
  26. data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
  27. data/lib/dispatch_policy/bypass.rb +23 -0
  28. data/lib/dispatch_policy/config.rb +85 -0
  29. data/lib/dispatch_policy/context.rb +50 -0
  30. data/lib/dispatch_policy/cursor_pagination.rb +121 -0
  31. data/lib/dispatch_policy/decision.rb +22 -0
  32. data/lib/dispatch_policy/engine.rb +4 -27
  33. data/lib/dispatch_policy/forwarder.rb +63 -0
  34. data/lib/dispatch_policy/gate.rb +10 -38
  35. data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
  36. data/lib/dispatch_policy/gates/concurrency.rb +45 -26
  37. data/lib/dispatch_policy/gates/throttle.rb +65 -37
  38. data/lib/dispatch_policy/inflight_tracker.rb +174 -0
  39. data/lib/dispatch_policy/job_extension.rb +155 -0
  40. data/lib/dispatch_policy/operator_hints.rb +126 -0
  41. data/lib/dispatch_policy/pipeline.rb +48 -0
  42. data/lib/dispatch_policy/policy.rb +62 -47
  43. data/lib/dispatch_policy/policy_dsl.rb +120 -0
  44. data/lib/dispatch_policy/railtie.rb +35 -0
  45. data/lib/dispatch_policy/registry.rb +46 -0
  46. data/lib/dispatch_policy/repository.rb +723 -0
  47. data/lib/dispatch_policy/serializer.rb +36 -0
  48. data/lib/dispatch_policy/tick.rb +263 -172
  49. data/lib/dispatch_policy/tick_loop.rb +59 -26
  50. data/lib/dispatch_policy/version.rb +1 -1
  51. data/lib/dispatch_policy.rb +71 -46
  52. data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
  53. data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
  54. data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
  55. data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
  56. metadata +101 -43
  57. data/CHANGELOG.md +0 -12
  58. data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
  59. data/app/models/dispatch_policy/partition_observation.rb +0 -49
  60. data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
  61. data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
  62. data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
  63. data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
  64. data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
  65. data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
  66. data/lib/dispatch_policy/dispatch_context.rb +0 -53
  67. data/lib/dispatch_policy/dispatchable.rb +0 -120
  68. data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
  69. data/lib/dispatch_policy/gates/global_cap.rb +0 -26
  70. 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
- <title>DispatchPolicy</title>
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
- <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
-
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
- const KEY = "dispatch_policy_refresh_seconds";
62
- let refreshTimer = null;
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 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);
26
+ function getInterval() {
27
+ return parseInt(sessionStorage.getItem(KEY) || "0", 10);
78
28
  }
79
29
 
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
- });
30
+ function setInterval(value) {
31
+ sessionStorage.setItem(KEY, String(value));
32
+ syncControls();
33
+ restart();
94
34
  }
95
35
 
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());
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
- // 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; }
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 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
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
- // Watched-partitions manager. Scopes the localStorage list to the
201
- // current policy (via [data-policy-name]); watch/unwatch clicks
202
- // rewrite the URL's `watch` param and Turbo.visit into it.
203
- function policyKey() {
204
- const el = document.querySelector("[data-policy-name]");
205
- return el ? `dp_watched:${el.dataset.policyName}` : null;
206
- }
207
- function getWatched() {
208
- const key = policyKey(); if (!key) return [];
209
- try { return JSON.parse(localStorage.getItem(key) || "[]"); } catch (_) { return []; }
210
- }
211
- function setWatched(arr) {
212
- const key = policyKey(); if (!key) return;
213
- localStorage.setItem(key, JSON.stringify(arr));
214
- const url = new URL(location.href);
215
- if (arr.length) url.searchParams.set("watch", arr.join(","));
216
- else url.searchParams.delete("watch");
217
- if (window.Turbo) Turbo.visit(url.toString(), { action: "replace" });
218
- else location.href = url.toString();
72
+ function init() {
73
+ bindControls();
74
+ restart();
219
75
  }
220
76
 
221
- function wireWatch() {
222
- document.addEventListener("click", (e) => {
223
- const add = e.target.closest("[data-watch]");
224
- const rm = e.target.closest("[data-unwatch]");
225
- if (!add && !rm) return;
226
- e.preventDefault();
227
- const arr = getWatched();
228
- if (add) {
229
- if (!arr.includes(add.dataset.watch)) arr.push(add.dataset.watch);
230
- } else {
231
- const idx = arr.indexOf(rm.dataset.unwatch);
232
- if (idx >= 0) arr.splice(idx, 1);
233
- }
234
- setWatched(arr);
235
- }, { once: true });
236
- }
77
+ document.addEventListener("DOMContentLoaded", init);
78
+ document.addEventListener("turbo:load", init);
237
79
 
238
- // On first load, if localStorage has a watched list but the URL
239
- // doesn't, inject it so the page renders the right partitions.
240
- function syncWatchedIntoUrl() {
241
- const arr = getWatched();
242
- if (!arr.length) return;
243
- const url = new URL(location.href);
244
- if (url.searchParams.get("watch") === arr.join(",")) return;
245
- url.searchParams.set("watch", arr.join(","));
246
- if (window.Turbo) Turbo.visit(url.toString(), { action: "replace" });
247
- else location.replace(url.toString());
248
- }
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
- function boot() {
251
- destroyExistingCharts();
252
- wireRefreshButtons();
253
- wireWatch();
254
- renderCharts();
255
- scheduleRefresh();
256
- syncWatchedIntoUrl();
257
- }
258
-
259
- // Run on the initial load + whenever Turbo renders (morph or full).
260
- document.addEventListener("DOMContentLoaded", boot);
261
- document.addEventListener("turbo:render", boot);
262
- document.addEventListener("turbo:load", boot);
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: "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]
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