job_harbor 0.6.0 → 0.7.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +136 -16
  3. data/app/assets/stylesheets/job_harbor/application.css +1 -1
  4. data/app/assets/stylesheets/job_harbor/application.tailwind.css +8 -0
  5. data/app/components/job_harbor/badge_component.rb +5 -3
  6. data/app/components/job_harbor/chart_component.rb +2 -4
  7. data/app/components/job_harbor/failure_rates_component.rb +19 -7
  8. data/app/components/job_harbor/job_row_component.rb +1 -1
  9. data/app/components/job_harbor/per_page_selector_component.rb +1 -3
  10. data/app/components/job_harbor/queue_card_component.rb +3 -2
  11. data/app/components/job_harbor/refresh_selector_component.rb +9 -4
  12. data/app/components/job_harbor/theme_toggle_component.rb +1 -2
  13. data/app/components/job_harbor/worker_card_component.rb +19 -12
  14. data/app/controllers/job_harbor/application_controller.rb +7 -3
  15. data/app/controllers/job_harbor/dashboard_controller.rb +1 -1
  16. data/app/controllers/job_harbor/jobs_controller.rb +46 -39
  17. data/app/controllers/job_harbor/queues_controller.rb +4 -5
  18. data/app/controllers/job_harbor/recurring_tasks_controller.rb +36 -8
  19. data/app/controllers/job_harbor/workers_controller.rb +1 -1
  20. data/app/models/job_harbor/chart_data.rb +22 -63
  21. data/app/models/job_harbor/dashboard_stats.rb +3 -20
  22. data/app/models/job_harbor/failure_stats.rb +8 -44
  23. data/app/models/job_harbor/job_presenter.rb +80 -131
  24. data/app/models/job_harbor/page_info.rb +9 -0
  25. data/app/models/job_harbor/processes.rb +25 -0
  26. data/app/models/job_harbor/queue_stats.rb +15 -12
  27. data/app/models/job_harbor/recurring_task_arguments.rb +29 -0
  28. data/app/models/job_harbor/sensitive_value_filter.rb +68 -0
  29. data/app/views/job_harbor/jobs/index.html.erb +12 -8
  30. data/app/views/job_harbor/jobs/search.html.erb +3 -3
  31. data/app/views/job_harbor/jobs/show.html.erb +15 -5
  32. data/app/views/job_harbor/queues/show.html.erb +9 -12
  33. data/app/views/job_harbor/recurring_tasks/show.html.erb +6 -6
  34. data/app/views/layouts/job_harbor/application.html.erb +58 -27
  35. data/lib/job_harbor/configuration.rb +30 -3
  36. data/lib/job_harbor/engine.rb +4 -11
  37. data/lib/job_harbor/version.rb +1 -1
  38. metadata +19 -12
  39. data/lib/tasks/solidqueue_dashboard_tasks.rake +0 -4
  40. data/lib/tasks/tailwind.rake +0 -129
@@ -2,13 +2,16 @@
2
2
 
3
3
  module JobHarbor
4
4
  class JobsController < ApplicationController
5
+ BULK_BATCH_SIZE = 500
6
+
5
7
  before_action :set_job, only: [ :show, :retry, :discard ]
8
+ before_action :reject_malformed_filters, only: [ :retry_all, :discard_all ]
6
9
 
7
10
  def index
8
11
  @status = params[:status]
9
12
  @per_page = per_page_param
10
- @class_name = params[:class_name]
11
- @queue_name = params[:queue_name]
13
+ @class_name = filter_param(:class_name)
14
+ @queue_name = filter_param(:queue_name)
12
15
 
13
16
  @pagy, @jobs = JobPresenter.all_with_status(
14
17
  @status,
@@ -18,6 +21,7 @@ module JobHarbor
18
21
  queue_name: @queue_name
19
22
  )
20
23
  @counts = job_counts
24
+ @bulk_count = bulk_scope(@status)&.count
21
25
  @filter_data = filter_data
22
26
  set_page_title @status ? "#{@status.titleize} Jobs" : "All Jobs"
23
27
  end
@@ -47,24 +51,23 @@ module JobHarbor
47
51
  end
48
52
 
49
53
  def discard
50
- if @job.can_discard?
51
- perform_discard(@job)
54
+ if @job.can_discard? && @job.discard
52
55
  redirect_to jobs_path(status: params[:return_status]), notice: "Job has been discarded."
53
56
  else
54
57
  redirect_to job_path(@job), alert: "This job cannot be discarded."
55
58
  end
59
+ rescue SolidQueue::Execution::UndiscardableError
60
+ redirect_to job_path(@job), alert: "This job cannot be discarded."
56
61
  end
57
62
 
58
63
  def retry_all
59
- status = params[:status]
60
- count = retry_all_jobs(status)
61
- redirect_to jobs_path(status: status), notice: "#{count} jobs queued for retry."
64
+ count = retry_all_jobs(params[:status])
65
+ redirect_to bulk_redirect_path, notice: "#{helpers.pluralize(count, "job")} queued for retry."
62
66
  end
63
67
 
64
68
  def discard_all
65
- status = params[:status]
66
- count = discard_all_jobs(status)
67
- redirect_to jobs_path(status: status), notice: "#{count} jobs discarded."
69
+ count = discard_all_jobs(params[:status])
70
+ redirect_to bulk_redirect_path, notice: "#{helpers.pluralize(count, "job")} discarded."
68
71
  end
69
72
 
70
73
  private
@@ -107,44 +110,48 @@ module JobHarbor
107
110
  failed_execution.retry
108
111
  end
109
112
 
110
- def perform_discard(job)
111
- # Remove from any execution tables
112
- SolidQueue::FailedExecution.where(job_id: job.id).delete_all
113
- SolidQueue::BlockedExecution.where(job_id: job.id).delete_all
114
- SolidQueue::ScheduledExecution.where(job_id: job.id).delete_all
115
- SolidQueue::ReadyExecution.where(job_id: job.id).delete_all
113
+ # Nil for tabs without bulk actions.
114
+ def bulk_scope(status)
115
+ executions = case status
116
+ when "failed" then SolidQueue::FailedExecution
117
+ when "blocked" then SolidQueue::BlockedExecution
118
+ end
119
+ return unless executions
120
+
121
+ JobPresenter.apply_filters(
122
+ SolidQueue::Job.where(id: executions.select(:job_id)),
123
+ class_name: filter_param(:class_name),
124
+ queue_name: filter_param(:queue_name)
125
+ )
126
+ end
116
127
 
117
- # Mark job as finished (discarded)
118
- SolidQueue::Job.where(id: job.id).update_all(finished_at: Time.current)
128
+ def reject_malformed_filters
129
+ head :bad_request unless %i[ class_name queue_name ].all? { |key| params[key].nil? || params[key].is_a?(String) }
130
+ end
131
+
132
+ def filter_param(key)
133
+ params[key] if params[key].is_a?(String)
134
+ end
135
+
136
+ def bulk_redirect_path
137
+ jobs_path(status: params[:status], class_name: filter_param(:class_name).presence, queue_name: filter_param(:queue_name).presence)
119
138
  end
120
139
 
121
140
  def retry_all_jobs(status)
122
141
  return 0 unless status == "failed"
123
142
 
124
- count = 0
125
- SolidQueue::FailedExecution.find_each do |fe|
126
- fe.retry
127
- count += 1
128
- end
129
- count
143
+ bulk_scope(status).in_batches(of: BULK_BATCH_SIZE).sum { |jobs| SolidQueue::FailedExecution.retry_all(jobs) }
130
144
  end
131
145
 
132
146
  def discard_all_jobs(status)
133
- case status
134
- when "failed"
135
- count = SolidQueue::FailedExecution.count
136
- job_ids = SolidQueue::FailedExecution.pluck(:job_id)
137
- SolidQueue::FailedExecution.delete_all
138
- SolidQueue::Job.where(id: job_ids).update_all(finished_at: Time.current)
139
- count
140
- when "blocked"
141
- count = SolidQueue::BlockedExecution.count
142
- job_ids = SolidQueue::BlockedExecution.pluck(:job_id)
143
- SolidQueue::BlockedExecution.delete_all
144
- SolidQueue::Job.where(id: job_ids).update_all(finished_at: Time.current)
145
- count
146
- else
147
- 0
147
+ scope = bulk_scope(status)
148
+ return 0 unless scope
149
+
150
+ executions = status == "failed" ? SolidQueue::FailedExecution : SolidQueue::BlockedExecution
151
+ scope.in_batches(of: BULK_BATCH_SIZE).sum do |jobs|
152
+ ids = jobs.ids
153
+ executions.discard_all_from_jobs(jobs)
154
+ ids.size - SolidQueue::Job.where(id: ids).count
148
155
  end
149
156
  end
150
157
  end
@@ -13,20 +13,19 @@ module JobHarbor
13
13
  @pagy, @jobs = JobPresenter.all_with_status(
14
14
  nil,
15
15
  page: params[:page],
16
- per_page: sq_config.jobs_per_page
16
+ per_page: sq_config.jobs_per_page,
17
+ queue_name: @queue.name
17
18
  )
18
- # Filter to only jobs in this queue
19
- @jobs = @jobs.select { |j| j.queue_name == @queue.name }
20
19
  set_page_title "Queue: #{@queue.name}"
21
20
  end
22
21
 
23
22
  def pause
24
- @queue.pause!
23
+ @queue.pause
25
24
  redirect_to queues_path, notice: "Queue '#{@queue.name}' has been paused."
26
25
  end
27
26
 
28
27
  def resume
29
- @queue.resume!
28
+ @queue.resume
30
29
  redirect_to queues_path, notice: "Queue '#{@queue.name}' has been resumed."
31
30
  end
32
31
 
@@ -2,7 +2,10 @@
2
2
 
3
3
  module JobHarbor
4
4
  class RecurringTasksController < ApplicationController
5
+ RECENT_EXECUTIONS_LIMIT = 20
6
+
5
7
  before_action :set_task, only: [ :show, :enqueue_now ]
8
+ before_action :ensure_job_class_defined, only: :enqueue_now
6
9
 
7
10
  def index
8
11
  @tasks = SolidQueue::RecurringTask.order(:key)
@@ -10,22 +13,47 @@ module JobHarbor
10
13
  end
11
14
 
12
15
  def show
13
- @recent_executions = SolidQueue::RecurringExecution
14
- .where(task_key: @task.key)
15
- .order(created_at: :desc)
16
- .limit(20)
16
+ executions = SolidQueue::RecurringExecution.where(task_key: @task.key)
17
+ @task_arguments = RecurringTaskArguments.new(@task)
18
+ @executions_count = executions.count
19
+ @last_run_at = executions.maximum(:run_at)
20
+ @recent_executions = executions.order(run_at: :desc).limit(RECENT_EXECUTIONS_LIMIT)
17
21
  set_page_title "Task: #{@task.key}"
18
22
  end
19
23
 
20
24
  def enqueue_now
21
- @task.enqueue(at: Time.current)
22
- redirect_to recurring_task_path(@task), notice: "Task '#{@task.key}' has been enqueued."
23
- rescue => e
24
- redirect_to recurring_task_path(@task), alert: "Failed to enqueue task: #{e.message}"
25
+ if enqueued?(at: Time.current.change(usec: 0))
26
+ redirect_to recurring_task_path(@task), notice: "Task '#{@task.key}' has been enqueued."
27
+ else
28
+ redirect_to recurring_task_path(@task),
29
+ alert: "Task '#{@task.key}' was not enqueued. It may have just run, or the job refused to enqueue; check the logs."
30
+ end
25
31
  end
26
32
 
27
33
  private
28
34
 
35
+ # Whole seconds, like cron ticks, so same-second clicks hit SQ's unique (task_key, run_at).
36
+ # SQ 1.0.x records a halted job with a nil job_id; the NotNullViolation rolls back.
37
+ def enqueued?(at:)
38
+ job = @task.enqueue(at: at)
39
+ job.present? && job.successfully_enqueued?
40
+ rescue ActiveRecord::NotNullViolation => error
41
+ Rails.error.report(error, handled: true)
42
+ false
43
+ end
44
+
45
+ # SQ's job_class is nil for an undefined class; blank class_name uses SQ's default job.
46
+ def ensure_job_class_defined
47
+ class_name = @task.class_name
48
+ return if class_name.blank?
49
+
50
+ job_class = class_name.safe_constantize
51
+ return if job_class.is_a?(Class) && job_class < ActiveJob::Base
52
+
53
+ redirect_to recurring_task_path(@task),
54
+ alert: "Task '#{@task.key}' can't run: #{class_name} isn't a job class defined in this app."
55
+ end
56
+
29
57
  def set_task
30
58
  @task = SolidQueue::RecurringTask.find(params[:id])
31
59
  rescue ActiveRecord::RecordNotFound
@@ -4,7 +4,7 @@ module JobHarbor
4
4
  class WorkersController < ApplicationController
5
5
  def index
6
6
  @workers = SolidQueue::Process.order(last_heartbeat_at: :desc)
7
- @active_count = @workers.where("last_heartbeat_at > ?", 5.minutes.ago).count
7
+ @active_count = Processes.alive.count
8
8
  @stale_count = @workers.count - @active_count
9
9
  set_page_title "Workers"
10
10
  end
@@ -13,7 +13,7 @@ module JobHarbor
13
13
  def initialize(range: "24h")
14
14
  @range = RANGES[range] || RANGES["24h"]
15
15
  @duration = @range[:duration]
16
- @interval = @range[:interval]
16
+ @interval = @range[:interval].to_i
17
17
  @cutoff = Time.current - @duration
18
18
  end
19
19
 
@@ -22,83 +22,42 @@ module JobHarbor
22
22
  end
23
23
 
24
24
  def series
25
- @series ||= calculate_series
25
+ @series ||= {
26
+ labels: bucket_starts.map { |time| format_label(time) },
27
+ completed: counts_per_bucket(SolidQueue::Job, :finished_at),
28
+ failed: counts_per_bucket(SolidQueue::FailedExecution, :created_at),
29
+ enqueued: counts_per_bucket(SolidQueue::Job, :created_at)
30
+ }
26
31
  end
27
32
 
28
33
  private
29
34
 
30
- def calculate_series
31
- buckets = generate_time_buckets
32
- labels = buckets.map { |t| format_label(t) }
33
-
34
- completed = count_by_bucket(completed_jobs, :finished_at, buckets)
35
- failed = count_by_bucket(failed_jobs, :created_at, buckets)
36
- enqueued = count_by_bucket(enqueued_jobs, :created_at, buckets)
37
-
38
- {
39
- labels: labels,
40
- completed: completed,
41
- failed: failed,
42
- enqueued: enqueued
43
- }
35
+ def bucket_count
36
+ @duration.to_i / @interval
44
37
  end
45
38
 
46
- def generate_time_buckets
47
- buckets = []
48
- current = @cutoff
49
- while current <= Time.current
50
- buckets << current
51
- current += @interval
52
- end
53
- buckets
39
+ def bucket_starts
40
+ Array.new(bucket_count) { |index| @cutoff + index * @interval }
54
41
  end
55
42
 
56
43
  def format_label(time)
57
- if @duration <= 1.hour
58
- time.strftime("%H:%M")
59
- elsif @duration <= 24.hours
60
- time.strftime("%H:%M")
61
- else
62
- time.strftime("%b %d")
63
- end
44
+ @duration <= 24.hours ? time.strftime("%H:%M") : time.strftime("%b %d")
64
45
  end
65
46
 
66
- def count_by_bucket(scope, time_field, buckets)
67
- # Get counts grouped by time bucket
68
- counts = scope
69
- .where("#{time_field} >= ?", @cutoff)
70
- .group_by_period(@interval, time_field)
47
+ # One row of SUM(CASE ...) per series: ANSI SQL, so it needs no per-database date truncation.
48
+ def counts_per_bucket(model, time_field)
49
+ column = "#{model.quoted_table_name}.#{model.connection.quote_column_name(time_field)}"
50
+ sums = bucket_starts.each_with_index.map { |start, index| bucket_sum(model, column, start, last: index == bucket_count - 1) }
71
51
 
72
- # Map to bucket array
73
- buckets.map do |bucket_start|
74
- bucket_end = bucket_start + @interval
75
- counts.count { |time, _| time >= bucket_start && time < bucket_end }
76
- end
77
- rescue => e
78
- # Fallback to simple counting if group_by_period isn't available
79
- buckets.map do |bucket_start|
80
- bucket_end = bucket_start + @interval
81
- scope.where("#{time_field} >= ? AND #{time_field} < ?", bucket_start, bucket_end).count
82
- end
52
+ model.where(time_field => @cutoff..).pick(*sums).map(&:to_i)
83
53
  end
84
54
 
85
- def completed_jobs
86
- SolidQueue::Job.where.not(finished_at: nil)
87
- end
88
-
89
- def failed_jobs
90
- SolidQueue::FailedExecution.all
91
- end
92
-
93
- def enqueued_jobs
94
- SolidQueue::Job.all
95
- end
55
+ # The last bucket is open-ended so a job stamped at the instant of the query still counts.
56
+ def bucket_sum(model, column, start, last:)
57
+ within = last ? "#{column} >= ?" : "#{column} >= ? AND #{column} < ?"
58
+ binds = last ? [ start ] : [ start, start + @interval ]
96
59
 
97
- def group_by_period(scope, interval, time_field)
98
- # Simple implementation - group records by time bucket
99
- scope.pluck(time_field).group_by do |time|
100
- time.to_i / interval.to_i * interval.to_i
101
- end.transform_keys { |ts| Time.at(ts) }
60
+ Arel.sql(model.sanitize_sql_array([ "SUM(CASE WHEN #{within} THEN 1 ELSE 0 END)", *binds ]))
102
61
  end
103
62
  end
104
63
  end
@@ -11,10 +11,6 @@ module JobHarbor
11
11
  calculate_stats
12
12
  end
13
13
 
14
- def total_jobs
15
- pending_count + scheduled_count + in_progress_count + failed_count + blocked_count
16
- end
17
-
18
14
  private
19
15
 
20
16
  def calculate_stats
@@ -51,32 +47,19 @@ module JobHarbor
51
47
  end
52
48
 
53
49
  def finished_jobs_count
54
- # Count jobs finished in the last 24 hours
55
- if SolidQueue::Job.column_names.include?("finished_at")
56
- SolidQueue::Job.where("finished_at > ?", 24.hours.ago).count
57
- else
58
- 0
59
- end
50
+ SolidQueue::Job.where("finished_at > ?", 24.hours.ago).count
60
51
  end
61
52
 
62
53
  def active_workers_count
63
- SolidQueue::Process.where("last_heartbeat_at > ?", 5.minutes.ago).count
54
+ Processes.live_workers.count
64
55
  end
65
56
 
66
57
  def queues_count_calc
67
- SolidQueue::Queue.count
68
- rescue
69
- # Fallback: count unique queue names from jobs
70
58
  SolidQueue::Job.distinct.count(:queue_name)
71
59
  end
72
60
 
73
61
  def calculate_throughput
74
- # Jobs finished in the last hour
75
- if SolidQueue::Job.column_names.include?("finished_at")
76
- SolidQueue::Job.where("finished_at > ?", 1.hour.ago).count
77
- else
78
- 0
79
- end
62
+ SolidQueue::Job.where("finished_at > ?", 1.hour.ago).count
80
63
  end
81
64
 
82
65
  def fetch_recent_failures
@@ -3,61 +3,25 @@
3
3
  module JobHarbor
4
4
  class FailureStats
5
5
  WINDOW = 24.hours
6
- LOW_THRESHOLD = 5
7
- MEDIUM_THRESHOLD = 20
8
-
9
- def initialize(window: WINDOW)
10
- @window = window
11
- @cutoff = Time.current - @window
12
- end
13
6
 
14
7
  def stats
15
8
  @stats ||= calculate_stats
16
9
  end
17
10
 
18
- def self.rate_badge_class(rate)
19
- case rate
20
- when 0...LOW_THRESHOLD
21
- "sqd-rate-low"
22
- when LOW_THRESHOLD...MEDIUM_THRESHOLD
23
- "sqd-rate-medium"
24
- else
25
- "sqd-rate-high"
26
- end
27
- end
28
-
29
11
  private
30
12
 
31
13
  def calculate_stats
32
- # Get all jobs in the window period
33
- recent_jobs = SolidQueue::Job.where("created_at >= ?", @cutoff)
34
-
35
- # Group by class_name and calculate totals
36
- job_counts = recent_jobs.group(:class_name).count
37
-
38
- # Get failed job counts (jobs that have failed executions)
39
- failed_job_ids = SolidQueue::FailedExecution
40
- .joins(:job)
41
- .where("solid_queue_jobs.created_at >= ?", @cutoff)
42
- .pluck(:job_id)
14
+ since = Time.current - WINDOW
15
+ recent_jobs = SolidQueue::Job.where(created_at: since..)
43
16
 
44
- failed_counts = SolidQueue::Job
45
- .where(id: failed_job_ids)
46
- .group(:class_name)
47
- .count
17
+ totals = recent_jobs.group(:class_name).count
18
+ failures = recent_jobs.joins(:failed_execution).group(:class_name).count
48
19
 
49
- # Build stats array
50
- job_counts.map do |class_name, total|
51
- failed = failed_counts[class_name] || 0
52
- rate = total > 0 ? (failed.to_f / total * 100).round(1) : 0.0
20
+ totals.map do |class_name, total|
21
+ failed = failures.fetch(class_name, 0)
53
22
 
54
- {
55
- class_name: class_name,
56
- total: total,
57
- failed: failed,
58
- rate: rate
59
- }
60
- end.sort_by { |s| -s[:rate] }
23
+ { class_name: class_name, total: total, failed: failed, rate: (failed.to_f / total * 100).round(1) }
24
+ end.sort_by { |stat| -stat[:rate] }
61
25
  end
62
26
  end
63
27
  end