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
@@ -0,0 +1,157 @@
1
+ /* dispatch_policy: minimal flat dashboard */
2
+
3
+ * { box-sizing: border-box; }
4
+
5
+ body {
6
+ margin: 0;
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
8
+ font-size: 14px;
9
+ color: #1a1a1a;
10
+ background: #f6f7fa;
11
+ }
12
+
13
+ a, a:visited { color: #1f4ed8; text-decoration: none; }
14
+ a:hover { text-decoration: underline; }
15
+ code { font-family: "SFMono-Regular", Menlo, monospace; font-size: 13px; background: #eef2f7; padding: 1px 4px; border-radius: 3px; }
16
+
17
+ .dp-header {
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ padding: 12px 28px;
22
+ background: #1d2330;
23
+ color: #f6f7fa;
24
+ border-bottom: 1px solid #0e1218;
25
+ }
26
+ .dp-header a { color: #f6f7fa; }
27
+ .dp-logo { font-weight: 600; font-size: 16px; letter-spacing: 0.4px; }
28
+ .dp-nav { flex: 1; }
29
+ .dp-nav a { margin-left: 22px; opacity: 0.85; }
30
+ .dp-nav a:hover { opacity: 1; text-decoration: none; }
31
+
32
+ .dp-refresh {
33
+ display: flex; align-items: center; gap: 6px;
34
+ font-size: 12px; color: rgba(246, 247, 250, 0.7);
35
+ }
36
+ .dp-refresh-label {
37
+ margin-right: 4px; text-transform: uppercase; letter-spacing: 0.6px; font-size: 10.5px;
38
+ }
39
+ .dp-refresh-btn {
40
+ background: transparent; color: rgba(246, 247, 250, 0.85);
41
+ border: 1px solid rgba(246, 247, 250, 0.25);
42
+ padding: 3px 9px; border-radius: 3px;
43
+ font-size: 12px; cursor: pointer;
44
+ font-variant-numeric: tabular-nums;
45
+ }
46
+ .dp-refresh-btn:hover { background: rgba(246, 247, 250, 0.08); }
47
+ .dp-refresh-btn.dp-refresh-active {
48
+ background: rgba(246, 247, 250, 0.92); color: #1d2330;
49
+ border-color: rgba(246, 247, 250, 0.92); font-weight: 600;
50
+ }
51
+
52
+ .dp-main { max-width: 1200px; margin: 24px auto; padding: 0 28px 64px; }
53
+ .dp-footer {
54
+ display: flex; gap: 24px; justify-content: space-between;
55
+ max-width: 1200px; margin: 0 auto; padding: 12px 28px 24px;
56
+ color: #6b7280; font-size: 12px;
57
+ }
58
+
59
+ h1 { font-size: 22px; margin: 0 0 18px; font-weight: 600; }
60
+ h2 { font-size: 15px; margin: 24px 0 10px; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.6px; }
61
+
62
+ .dp-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 18px; }
63
+ .dp-stat {
64
+ background: #fff; border: 1px solid #e3e6ec; border-radius: 6px;
65
+ padding: 14px 16px; display: flex; flex-direction: column; gap: 6px;
66
+ }
67
+ .dp-stat-label { color: #6b7280; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; }
68
+ .dp-stat-value { font-size: 22px; font-weight: 600; }
69
+
70
+ .dp-section { background: #fff; border: 1px solid #e3e6ec; border-radius: 6px; padding: 16px 20px; margin: 14px 0; }
71
+
72
+ .dp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
73
+ .dp-table th, .dp-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eef0f4; }
74
+ .dp-table th { background: #f1f3f7; font-weight: 600; color: #374151; }
75
+ .dp-table tr:hover td { background: #fafbfd; }
76
+ .dp-num { text-align: right; font-variant-numeric: tabular-nums; }
77
+
78
+ .dp-empty { color: #6b7280; font-style: italic; padding: 6px 0; }
79
+
80
+ .dp-flash { padding: 10px 16px; border-radius: 0; font-size: 13px; }
81
+ .dp-flash-ok { background: #e8f6ee; color: #14532d; border-bottom: 1px solid #c7e6d3; }
82
+ .dp-flash-err { background: #fbe7e6; color: #7a1d1d; border-bottom: 1px solid #f0c2bf; }
83
+
84
+ .dp-warn { color: #b45309; }
85
+ .dp-row-paused td { background: #fffbe9; }
86
+
87
+ .dp-list { margin: 0; padding-left: 20px; }
88
+ .dp-list li { margin: 3px 0; }
89
+
90
+ .dp-link { color: #1f4ed8; }
91
+
92
+ .dp-form-inline { display: inline-block; margin-right: 6px; }
93
+ .dp-btn {
94
+ background: #fff; border: 1px solid #cfd5df; padding: 6px 12px;
95
+ font-size: 13px; border-radius: 4px; color: #1a1a1a; cursor: pointer;
96
+ }
97
+ .dp-btn:hover { background: #eef1f6; }
98
+ .dp-btn-ok { border-color: #2f9e58; color: #14532d; background: #f1faf3; }
99
+ .dp-btn-ok:hover { background: #def0e3; }
100
+ .dp-btn-warn { border-color: #d05858; color: #7a1d1d; background: #fdf2f1; }
101
+ .dp-btn-warn:hover { background: #f7dcd9; }
102
+
103
+ .dp-input {
104
+ padding: 6px 10px; border: 1px solid #cfd5df; border-radius: 4px;
105
+ font-size: 13px; min-width: 240px;
106
+ }
107
+ .dp-search-form { margin-bottom: 14px; }
108
+
109
+ .dp-json {
110
+ background: #0f1116; color: #c4cad6; padding: 12px 14px;
111
+ border-radius: 6px; overflow-x: auto;
112
+ font-family: "SFMono-Regular", Menlo, monospace; font-size: 12.5px; line-height: 1.45;
113
+ }
114
+
115
+ .dp-hint {
116
+ font-size: 12.5px; color: #6b7280; margin-top: 8px;
117
+ border-left: 3px solid #cfd5df; padding: 6px 12px; background: #fafbfd;
118
+ border-radius: 0 4px 4px 0;
119
+ }
120
+ .dp-hint code { background: #eef2f7; }
121
+
122
+ .dp-spark {
123
+ margin-top: 10px; font-size: 13px; color: #374151;
124
+ }
125
+ .dp-spark code {
126
+ font-family: "SFMono-Regular", Menlo, monospace; font-size: 16px;
127
+ background: #1d2330; color: #f6f7fa; padding: 2px 8px; letter-spacing: 1px;
128
+ }
129
+
130
+ .dp-hint-list { padding-left: 0; list-style: none; }
131
+ .dp-hint-list li {
132
+ margin: 8px 0; padding: 10px 14px;
133
+ border-left: 4px solid #cfd5df; background: #fafbfd;
134
+ border-radius: 0 4px 4px 0;
135
+ font-size: 13px; line-height: 1.5;
136
+ }
137
+ .dp-hint-list li.dp-hint-info { border-left-color: #1f4ed8; background: #eff4ff; }
138
+ .dp-hint-list li.dp-hint-warn { border-left-color: #b45309; background: #fff7e6; }
139
+ .dp-hint-list li.dp-hint-critical { border-left-color: #b91c1c; background: #fbe7e6; }
140
+ .dp-hint-badge {
141
+ display: inline-block; margin-right: 8px; padding: 1px 7px;
142
+ font-size: 10.5px; font-weight: 700; letter-spacing: 0.5px;
143
+ border-radius: 3px; color: #fff; background: #6b7280;
144
+ vertical-align: 1px;
145
+ }
146
+ .dp-hint-info .dp-hint-badge { background: #1f4ed8; }
147
+ .dp-hint-warn .dp-hint-badge { background: #b45309; }
148
+ .dp-hint-critical .dp-hint-badge { background: #b91c1c; }
149
+
150
+ .dp-pagination {
151
+ display: flex; justify-content: space-between; align-items: center;
152
+ padding: 14px 0; gap: 12px; flex-wrap: wrap;
153
+ }
154
+ .dp-pagination-info { color: #6b7280; font-size: 13px; font-variant-numeric: tabular-nums; }
155
+ .dp-pagination-nav { display: flex; gap: 6px; }
156
+ .dp-pagination-nav .dp-btn { padding: 4px 10px; font-size: 12.5px; text-decoration: none; }
157
+ .dp-btn-disabled { opacity: 0.4; cursor: default; pointer-events: none; }
@@ -4,6 +4,50 @@ module DispatchPolicy
4
4
  class ApplicationController < ActionController::Base
5
5
  protect_from_forgery with: :exception
6
6
 
7
- layout "dispatch_policy/application"
7
+ helper_method :format_time, :format_count, :format_duration_seconds,
8
+ :format_duration_ms, :sparkline, :registered_policies
9
+
10
+ private
11
+
12
+ def registered_policies
13
+ DispatchPolicy.registry.each.to_a
14
+ end
15
+
16
+ def format_time(time)
17
+ return "—" unless time
18
+ time.utc.strftime("%Y-%m-%d %H:%M:%S")
19
+ end
20
+
21
+ def format_count(value)
22
+ return "0" if value.nil?
23
+ value.to_i.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
24
+ end
25
+
26
+ def format_duration_seconds(seconds)
27
+ return "—" if seconds.nil?
28
+ s = seconds.to_f
29
+ return "%.0fms" % (s * 1000) if s < 1
30
+ return "%.1fs" % s if s < 60
31
+ return "%.1fm" % (s / 60) if s < 3600
32
+ "%.1fh" % (s / 3600)
33
+ end
34
+
35
+ def format_duration_ms(ms)
36
+ return "—" if ms.nil?
37
+ format_duration_seconds(ms.to_f / 1000.0)
38
+ end
39
+
40
+ BARS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
41
+
42
+ def sparkline(values, width: 30)
43
+ return "" if values.nil? || values.empty?
44
+
45
+ data = values.map(&:to_i)
46
+ data = data.last(width)
47
+ max = data.max
48
+ return BARS.first * data.size if max.nil? || max.zero?
49
+
50
+ data.map { |v| BARS[((v.to_f / max) * (BARS.size - 1)).round] }.join
51
+ end
8
52
  end
9
53
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ class DashboardController < ApplicationController
5
+ WINDOWS = { "1m" => 60, "5m" => 5 * 60, "15m" => 15 * 60 }.freeze
6
+
7
+ def index
8
+ @totals = {
9
+ staged: StagedJob.count,
10
+ partitions: Partition.count,
11
+ active_parts: Partition.active.count,
12
+ paused_parts: Partition.paused.count,
13
+ in_flight: InflightJob.count
14
+ }
15
+
16
+ now = Time.current
17
+ @windows = WINDOWS.transform_values { |secs| Repository.tick_summary(since: now - secs) }
18
+ @round_trip = Repository.partition_round_trip_stats
19
+
20
+ # Pending trend: 30 minutes of 1-min buckets aggregated across
21
+ # all policies. Used for the sparkline + arrow on the overview.
22
+ @pending_buckets = Repository.tick_samples_buckets(since: now - 30 * 60, bucket_seconds: 60)
23
+ @pending_trend = Repository.trend_direction(@pending_buckets.map { |b| b[:pending_total] })
24
+
25
+ # Capacity headroom: live admit rate vs configured adapter ceiling,
26
+ # avg tick wall vs tick_max_duration. These two ratios are the
27
+ # operator's quickest "should I shard?" signal.
28
+ cfg = DispatchPolicy.config
29
+ @capacity = {
30
+ admitted_per_minute: @windows["1m"][:jobs_admitted],
31
+ admitted_per_second: @windows["1m"][:jobs_admitted] / 60.0,
32
+ adapter_target_jps: cfg.adapter_throughput_target,
33
+ avg_tick_ms: @windows["1m"][:avg_duration_ms],
34
+ max_tick_ms: @windows["1m"][:max_duration_ms],
35
+ tick_max_duration_ms: cfg.tick_max_duration.to_i * 1000
36
+ }
37
+
38
+ @hints = OperatorHints.for(
39
+ tick_max_duration_ms: @capacity[:tick_max_duration_ms],
40
+ avg_tick_ms: @capacity[:avg_tick_ms],
41
+ max_tick_ms: @capacity[:max_tick_ms],
42
+ pending_total: @totals[:staged],
43
+ admitted_per_minute: @capacity[:admitted_per_minute],
44
+ forward_failures: @windows["1m"][:forward_failures],
45
+ jobs_admitted: @windows["1m"][:jobs_admitted],
46
+ active_partitions: @round_trip[:active_partitions],
47
+ never_checked: @round_trip[:never_checked],
48
+ in_backoff: @round_trip[:in_backoff],
49
+ total_partitions: @totals[:partitions],
50
+ adapter_target_jps: @capacity[:adapter_target_jps],
51
+ pending_trend: @pending_trend
52
+ )
53
+
54
+ pending_by_policy = Partition
55
+ .group(:policy_name)
56
+ .pluck(:policy_name, Arel.sql("SUM(pending_count)::int"), Arel.sql("MAX(last_admit_at)"))
57
+ .to_h { |name, pending, last_admit| [name, { pending: pending || 0, last_admit_at: last_admit }] }
58
+
59
+ in_flight_by_policy = InflightJob.group(:policy_name).count
60
+
61
+ one_min_ago = now - 60
62
+ five_min_ago = now - 300
63
+
64
+ names = (pending_by_policy.keys + in_flight_by_policy.keys).uniq.sort
65
+ @policies = names.map do |name|
66
+ info = pending_by_policy[name] || {}
67
+ m1 = Repository.tick_summary(policy_name: name, since: one_min_ago)
68
+ m5 = Repository.tick_summary(policy_name: name, since: five_min_ago)
69
+ rs = Repository.denied_reasons_summary(policy_name: name, since: one_min_ago)
70
+ rt = Repository.partition_round_trip_stats(policy_name: name)
71
+
72
+ {
73
+ name: name,
74
+ pending: info[:pending] || 0,
75
+ in_flight: in_flight_by_policy[name] || 0,
76
+ last_admit_at: info[:last_admit_at],
77
+ admitted_1m: m1[:jobs_admitted],
78
+ admitted_5m: m5[:jobs_admitted],
79
+ ticks_1m: m1[:ticks],
80
+ avg_tick_ms_1m: m1[:avg_duration_ms],
81
+ forward_failures_1m: m1[:forward_failures],
82
+ oldest_age_seconds: rt[:oldest_age_seconds],
83
+ p95_age_seconds: rt[:p95_age_seconds],
84
+ in_backoff: rt[:in_backoff],
85
+ top_denial_reason: rs.first&.first,
86
+ top_denial_count: rs.first&.last
87
+ }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ class PartitionsController < ApplicationController
5
+ before_action :find_partition, only: %i[show drain admit]
6
+
7
+ DRAIN_MAX_PER_REQUEST = 10_000
8
+ DRAIN_BATCH_SIZE = 200
9
+
10
+ PAGE_SIZE = 100
11
+
12
+ def index
13
+ base = Partition.all
14
+ base = base.for_policy(params[:policy]) if params[:policy].present?
15
+ base = base.for_shard(params[:shard]) if params[:shard].present?
16
+ base = base.where("partition_key ILIKE ?", "%#{params[:q]}%") if params[:q].present?
17
+ base = base.where("pending_count > 0") if params[:only_pending] == "1"
18
+
19
+ @sort = DispatchPolicy::CursorPagination::SORTS.key?(params[:sort]) ? params[:sort] : DispatchPolicy::CursorPagination::DEFAULT_SORT
20
+ sort_def = DispatchPolicy::CursorPagination.sort_for(@sort)
21
+
22
+ @total = base.count # cheap on indexed columns; nice to display
23
+ @cursor = DispatchPolicy::CursorPagination.decode(params[:cursor])
24
+
25
+ paginated = DispatchPolicy::CursorPagination.apply(base, @sort, @cursor)
26
+ .order(Arel.sql(sort_def[:sql_order]))
27
+ .limit(PAGE_SIZE + 1)
28
+ .to_a
29
+
30
+ @has_more = paginated.size > PAGE_SIZE
31
+ @partitions = paginated.first(PAGE_SIZE)
32
+ @next_cursor =
33
+ if @has_more && @partitions.any?
34
+ v, id = DispatchPolicy::CursorPagination.extract(@partitions.last, @sort)
35
+ DispatchPolicy::CursorPagination.encode(v, id)
36
+ end
37
+
38
+ @policy = params[:policy]
39
+ @shard = params[:shard]
40
+ @query = params[:q]
41
+ @only_pending = params[:only_pending] == "1"
42
+
43
+ shards_scope = Partition.all
44
+ shards_scope = shards_scope.for_policy(params[:policy]) if params[:policy].present?
45
+ @shards = shards_scope.distinct.pluck(:shard).sort
46
+ end
47
+
48
+ # Build URL params preserving filters, replacing the cursor.
49
+ def pagination_params(overrides = {})
50
+ {
51
+ policy: @policy.presence,
52
+ shard: @shard.presence,
53
+ q: @query.presence,
54
+ sort: (@sort if @sort != DispatchPolicy::CursorPagination::DEFAULT_SORT),
55
+ only_pending: ("1" if @only_pending),
56
+ cursor: nil
57
+ }.compact.merge(overrides)
58
+ end
59
+ helper_method :pagination_params
60
+
61
+ def show
62
+ @recent_jobs = StagedJob
63
+ .for_partition(@partition.policy_name, @partition.partition_key)
64
+ .order(:scheduled_at, :id)
65
+ .limit(50)
66
+ @inflight = InflightJob.where(policy_name: @partition.policy_name).limit(50)
67
+ end
68
+
69
+ def admit
70
+ count = Integer(params[:count] || 1)
71
+ rows = Repository.claim_staged_jobs!(
72
+ policy_name: @partition.policy_name,
73
+ partition_key: @partition.partition_key,
74
+ limit: count,
75
+ gate_state_patch: {},
76
+ retry_after: nil
77
+ )
78
+ forwarded = rows.size - Forwarder.dispatch(rows).size
79
+ redirect_to partition_path(@partition), notice: "Forwarded #{forwarded} job(s)."
80
+ end
81
+
82
+ # Empties the partition by force-admitting every staged job through the
83
+ # forwarder, bypassing all gates. Bounded at DRAIN_MAX_PER_REQUEST so a
84
+ # huge backlog can't time the controller out — the operator clicks again
85
+ # for the next batch.
86
+ def drain
87
+ drained, remaining = self.class.drain_partition!(@partition)
88
+ notice = if remaining.positive?
89
+ "Drained #{drained} job(s); #{remaining} still pending — click drain again to continue."
90
+ else
91
+ "Drained #{drained} job(s); partition empty."
92
+ end
93
+ redirect_to partition_path(@partition), notice: notice
94
+ end
95
+
96
+ def self.drain_partition!(partition)
97
+ drained = 0
98
+ while drained < DRAIN_MAX_PER_REQUEST
99
+ batch_limit = [DRAIN_BATCH_SIZE, DRAIN_MAX_PER_REQUEST - drained].min
100
+ rows = Repository.claim_staged_jobs!(
101
+ policy_name: partition.policy_name,
102
+ partition_key: partition.partition_key,
103
+ limit: batch_limit,
104
+ gate_state_patch: {},
105
+ retry_after: nil
106
+ )
107
+ break if rows.empty?
108
+
109
+ Forwarder.dispatch(rows)
110
+ drained += rows.size
111
+ end
112
+ remaining = partition.class.where(id: partition.id).pick(:pending_count) || 0
113
+ [drained, remaining]
114
+ end
115
+
116
+ private
117
+
118
+ def find_partition
119
+ @partition = Partition.find(params[:id])
120
+ end
121
+ end
122
+ end