sidekiq 8.1.1 → 8.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4fb090d79e2cf2b320fdd47ee55a574a68a46b77a23db27b849ddb6f54acb00a
4
- data.tar.gz: 4863ef28ecdd8ad2cd868caf2f47cc9302b8eb88f4ff9da4f2b5641beca50d88
3
+ metadata.gz: 0d21db4cf06c0b4d0b5fb770162e289403a76ed996d6c99613cf45083f0e671d
4
+ data.tar.gz: 07cf848cd5de112f3153ecd0014991cbf2abf9e66bc0847d2b7845ec7a726329
5
5
  SHA512:
6
- metadata.gz: 4ba71a2c43cec613337119cd29830339e213a8f876db4db84c62ac67890ee020ab841a4f7fe478f7f0fcd135571b885e54c9af12998ea6947ac51b543d52b42c
7
- data.tar.gz: 9cabfc243aacec3c57c697b83374a5b934cc985c1e4ee86185a6faba7fe18f6540e3fa0b203c5dbfc0e22c03513f66e973be4152d8c8e12db29e97e21a94360f
6
+ metadata.gz: 395d346f8b5227480e4d830b3c1f219a35bb0e2527283f9c00a6a4043c1ebe271998874ae53240b6e7dabca89ce8ad939d0a3969da2b33c3e4155fccbebae9ba
7
+ data.tar.gz: 20f44288fd8990544569af7f2a87d90d4ce898b2ce760cc95dd2f30c5a0c4ffc90bcc087cbfa22dbc188a904cc2e817756a83b5b3ffd648f75dc0413d3bec3ff
data/Changes.md CHANGED
@@ -2,16 +2,31 @@
2
2
 
3
3
  [Sidekiq Changes](https://github.com/sidekiq/sidekiq/blob/main/Changes.md) | [Sidekiq Pro Changes](https://github.com/sidekiq/sidekiq/blob/main/Pro-Changes.md) | [Sidekiq Enterprise Changes](https://github.com/sidekiq/sidekiq/blob/main/Ent-Changes.md)
4
4
 
5
+ 8.1.2
6
+ ----------
7
+
8
+ - Initial release for `kiq`, Sidekiq's official terminal UI:
9
+ ```
10
+ bundle exec kiq
11
+ ```
12
+ Use REDIS_URL or REDIS_PROVIDER to point `kiq` to Redis.
13
+ - Mutation during iteration in `SortedSet#each` caused it to miss half of the jobs [#6936]
14
+ - Fix edge case resulting in nil crash on /busy page [#6954]
15
+
5
16
  8.1.1
6
17
  ----------
7
18
 
8
- - Add new `Sidekiq.testing!(mode)` API [#6931]
9
- Requiring code should not enable process-wide changes.
19
+ - **DEPRECATION** `require 'sidekiq/testing'` and
20
+ `require 'sidekiq/testing/inline'`.
21
+ Add new `Sidekiq.testing!(mode)` API [#6931]
22
+ Requiring code should not enable process-wide changes.
10
23
  ```ruby
11
24
  # Old, implicit
12
25
  require "sidekiq/testing"
26
+ require "sidekiq/testing/inline"
13
27
  # New, more explicit
14
28
  Sidekiq.testing!(:fake)
29
+ Sidekiq.testing!(:inline)
15
30
  ```
16
31
  - Fix race condition with Stop button in UI [#6935]
17
32
  - Fix javascript error handler [#6893]
data/README.md CHANGED
@@ -90,7 +90,7 @@ Useful resources:
90
90
  * The [Sidekiq tag](https://stackoverflow.com/questions/tagged/sidekiq) on Stack Overflow has lots of useful Q & A.
91
91
 
92
92
  Every Thursday morning is Sidekiq Office Hour: I video chat and answer questions.
93
- See the [Sidekiq support page](https://sidekiq.org/support.html) for details.
93
+ See the [Sidekiq support page](https://sidekiq.org/support/) for details.
94
94
 
95
95
  Contributing
96
96
  -----------------
data/bin/kiq ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # This requires the default gemset so Sidekiq Pro
4
+ # and Sidekiq Enterprise can load any code extensions.
5
+ Bundler.require(:default, :tui)
6
+
7
+ require_relative "../lib/sidekiq/tui"
8
+
9
+ # Run any load hooks registered during Bundler.require
10
+ Sidekiq.loader.run_load_hooks(:tui)
11
+
12
+ tt = Sidekiq::TUI.new(Sidekiq.default_configuration)
13
+
14
+ RatatuiRuby.run do |tui|
15
+ tt.prepare(tui)
16
+ tt.run_loop
17
+ end
@@ -63,19 +63,19 @@ begin
63
63
  def enqueue(job)
64
64
  # NB: Active Job only serializes keys it recognizes. We
65
65
  # cannot set arbitrary key/values here.
66
- wrapper = Sidekiq::ActiveJob::Wrapper.set(
67
- wrapped: job.class,
68
- queue: job.queue_name
69
- )
66
+ options = {wrapped: job.class, queue: job.queue_name}
67
+ options[:profile] = job.profile if job.respond_to?(:profile) && !job.profile.nil?
68
+
69
+ wrapper = Sidekiq::ActiveJob::Wrapper.set(options)
70
70
  job.provider_job_id = wrapper.perform_async(job.serialize)
71
71
  end
72
72
 
73
73
  # @api private
74
74
  def enqueue_at(job, timestamp)
75
- job.provider_job_id = Sidekiq::ActiveJob::Wrapper.set(
76
- wrapped: job.class,
77
- queue: job.queue_name
78
- ).perform_at(timestamp, job.serialize)
75
+ options = {wrapped: job.class, queue: job.queue_name}
76
+ options[:profile] = job.profile if job.respond_to?(:profile) && !job.profile.nil?
77
+
78
+ job.provider_job_id = Sidekiq::ActiveJob::Wrapper.set(options).perform_at(timestamp, job.serialize)
79
79
  end
80
80
 
81
81
  # @api private
data/lib/sidekiq/api.rb CHANGED
@@ -657,38 +657,8 @@ module Sidekiq
657
657
 
658
658
  private
659
659
 
660
- def remove_job
661
- Sidekiq.redis do |conn|
662
- results = conn.multi { |transaction|
663
- transaction.zrange(parent.name, score, score, "BYSCORE")
664
- transaction.zremrangebyscore(parent.name, score, score)
665
- }.first
666
-
667
- if results.size == 1
668
- yield results.first
669
- else
670
- # multiple jobs with the same score
671
- # find the one with the right JID and push it
672
- matched, nonmatched = results.partition { |message|
673
- if message.index(jid)
674
- msg = Sidekiq.load_json(message)
675
- msg["jid"] == jid
676
- else
677
- false
678
- end
679
- }
680
-
681
- msg = matched.first
682
- yield msg if msg
683
-
684
- # push the rest back onto the sorted set
685
- conn.multi do |transaction|
686
- nonmatched.each do |message|
687
- transaction.zadd(parent.name, score.to_f.to_s, message)
688
- end
689
- end
690
- end
691
- end
660
+ def remove_job(&)
661
+ parent.remove_job(self, &)
692
662
  end
693
663
  end
694
664
 
@@ -857,6 +827,46 @@ module Sidekiq
857
827
  nil
858
828
  end
859
829
 
830
+ def remove_job(entry)
831
+ score = entry.score
832
+ jid = entry.jid
833
+ Sidekiq.redis do |conn|
834
+ results = conn.multi { |transaction|
835
+ transaction.zrange(name, score, score, "BYSCORE")
836
+ transaction.zremrangebyscore(name, score, score)
837
+ }.first
838
+
839
+ if results.size == 1
840
+ yield results.first
841
+ @_size -= 1
842
+ else
843
+ # multiple jobs with the same score
844
+ # find the one with the right JID and push it
845
+ matched, nonmatched = results.partition { |message|
846
+ if message.index(jid)
847
+ msg = Sidekiq.load_json(message)
848
+ msg["jid"] == jid
849
+ else
850
+ false
851
+ end
852
+ }
853
+
854
+ msg = matched.first
855
+ if msg
856
+ yield msg
857
+ @_size -= 1
858
+ end
859
+
860
+ # push the rest back onto the sorted set
861
+ conn.multi do |transaction|
862
+ nonmatched.each do |message|
863
+ transaction.zadd(name, score.to_f.to_s, message)
864
+ end
865
+ end
866
+ end
867
+ end
868
+ end
869
+
860
870
  # :nodoc:
861
871
  # @api private
862
872
  def delete_by_value(name, value)
@@ -1030,19 +1040,20 @@ module Sidekiq
1030
1040
  # you'll be happier this way
1031
1041
  conn.pipelined do |pipeline|
1032
1042
  procs.each do |key|
1033
- pipeline.hmget(key, "info", "busy", "beat", "quiet", "rss", "rtt_us")
1043
+ pipeline.hmget(key, "info", "concurrency", "busy", "beat", "quiet", "rss", "rtt_us")
1034
1044
  end
1035
1045
  end
1036
1046
  }
1037
1047
 
1038
- result.each do |info, busy, beat, quiet, rss, rtt_us|
1048
+ result.each do |info, concurrency, busy, beat, quiet, rss, rtt_us|
1039
1049
  # If a process is stopped between when we query Redis for `procs` and
1040
1050
  # when we query for `result`, we will have an item in `result` that is
1041
1051
  # composed of `nil` values.
1042
1052
  next if info.nil?
1043
1053
 
1044
1054
  hash = Sidekiq.load_json(info)
1045
- yield Process.new(hash.merge("busy" => busy.to_i,
1055
+ yield Process.new(hash.merge("concurrency" => concurrency.to_i,
1056
+ "busy" => busy.to_i,
1046
1057
  "beat" => beat.to_f,
1047
1058
  "quiet" => quiet,
1048
1059
  "rss" => rss.to_i,
@@ -132,12 +132,14 @@ module Sidekiq
132
132
  # push_bulk('class' => MyJob, 'args' => (1..100_000).to_a, batch_size: 1_000)
133
133
  #
134
134
  def push_bulk(items)
135
- batch_size = items.delete(:batch_size) || items.delete("batch_size") || 1_000
136
135
  args = items["args"]
137
136
  at = items.delete("at") || items.delete(:at)
138
137
  raise ArgumentError, "Job 'at' must be a Numeric or an Array of Numeric timestamps" if at && (Array(at).empty? || !Array(at).all? { |entry| entry.is_a?(Numeric) })
139
138
  raise ArgumentError, "Job 'at' Array must have same size as 'args' Array" if at.is_a?(Array) && at.size != args.size
140
139
 
140
+ # Use a smaller batch size by default for scheduled jobs since adding to sorted sets is more costly.
141
+ batch_size = items.delete(:batch_size) || items.delete("batch_size") || (at ? 100 : 1_000)
142
+
141
143
  jid = items.delete("jid")
142
144
  raise ArgumentError, "Explicitly passing 'jid' when pushing more than one job is not supported" if jid && args.size > 1
143
145
 
@@ -57,6 +57,9 @@ module Sidekiq
57
57
  end
58
58
 
59
59
  def tid
60
+ # We XOR with PID to ensure Thread IDs changes after fork.
61
+ # I'm unclear why we don't multiply the two values to better guarantee
62
+ # a unique value but it's been this way for quite a while now. #3685
60
63
  Thread.current["sidekiq_tid"] ||= (Thread.current.object_id ^ ::Process.pid).to_s(36)
61
64
  end
62
65
 
@@ -176,6 +176,7 @@ module Sidekiq
176
176
  transaction.sadd("processes", [key])
177
177
  transaction.exists(key)
178
178
  transaction.hset(key, "info", to_json,
179
+ "concurrency", @config.total_concurrency,
179
180
  "busy", curstate.size,
180
181
  "beat", Time.now.to_f,
181
182
  "rtt_us", rtt,
@@ -257,7 +258,6 @@ module Sidekiq
257
258
  "started_at" => Time.now.to_f,
258
259
  "pid" => ::Process.pid,
259
260
  "tag" => @config[:tag] || "",
260
- "concurrency" => @config.total_concurrency,
261
261
  "capsules" => @config.capsules.each_with_object({}) { |(name, cap), memo|
262
262
  memo[name] = cap.to_h
263
263
  },
@@ -69,7 +69,7 @@ module Sidekiq
69
69
  def processor_result(processor, reason = nil)
70
70
  @plock.synchronize do
71
71
  @workers.delete(processor)
72
- unless @done
72
+ if !@done && @count > @workers.size
73
73
  p = Processor.new(@config, &method(:processor_result))
74
74
  @workers << p
75
75
  p.start
@@ -62,7 +62,12 @@ module Sidekiq
62
62
  pageidx = current_page - 1
63
63
  starting = pageidx * page_size
64
64
  items = items.to_a
65
- [current_page, items.size, items[starting, page_size]]
65
+ total_size = items.size
66
+ if starting > total_size
67
+ starting = 0
68
+ current_page = 1
69
+ end
70
+ [current_page, total_size, items[starting, page_size]]
66
71
  end
67
72
  end
68
73
  end
@@ -21,7 +21,7 @@ module Sidekiq
21
21
  return yield unless job["profile"]
22
22
 
23
23
  token = job["profile"]
24
- type = job["class"]
24
+ type = job["wrapped"] || job["class"]
25
25
  jid = job["jid"]
26
26
  started_at = Time.now
27
27
 
@@ -0,0 +1,53 @@
1
+ module Sidekiq
2
+ class TUI
3
+ module Controls
4
+ # Defines data for input handling and for displaying controls.
5
+ # :code is the key code for input handling.
6
+ # :display and :description are shown in the controls area, with different
7
+ # styling between them. If :display is omitted, :code is displayed instead.
8
+ # :action is a lambda to execute when the control is triggered.
9
+ # :refresh means the action requires immediate refreshing of data
10
+ #
11
+ # Conventions: dangerous/irreversible actions should use UPPERCASE codes.
12
+ # The Shift button means "I'm sure".
13
+ GLOBAL = [
14
+ {code: "?", display: "?", description: "Help", action: ->(tui, tab) { tui.show_help }},
15
+ {code: "left", display: "←/→", description: "Select Tab", action: ->(tui, tab) { tui.navigate(:left) }, refresh: true},
16
+ {code: "right", action: ->(tui, tab) { tui.navigate(:right) }, refresh: true},
17
+ {code: "q", description: "Quit", action: ->(tui, tab) { :quit }},
18
+ {code: "c", modifiers: ["ctrl"], action: ->(tui, tab) { :quit }}
19
+ ].freeze
20
+
21
+ SHARED = {
22
+ pageable: [
23
+ {code: "h", display: "h/l", description: "Prev/Next Page",
24
+ action: ->(tui, tab) { tab.prev_page }, refresh: true},
25
+ {code: "l", action: ->(tui, tab) { tab.next_page }, refresh: true}
26
+ ],
27
+ selectable: [
28
+ {code: "k", display: "j/k", description: "Prev/Next Row",
29
+ action: ->(tui, tab) { tab.navigate_row(:up) }},
30
+ {code: "j", action: ->(tui, tab) { tab.navigate_row(:down) }},
31
+ {code: "x", description: "Select", action: ->(tui, tab) { tab.toggle_select }},
32
+ {code: "A", modifiers: ["shift"], display: "A", description: "Select All",
33
+ action: ->(tui, tab) { tab.toggle_select(:all) }}
34
+ ],
35
+ filterable: [
36
+ {code: "/", display: "/", description: "Filter", action: ->(tui, tab) { tab.start_filtering }},
37
+ {code: "backspace", action: ->(tui, tab) { tab.remove_last_char_from_filter }, refresh: true},
38
+ {code: "enter", action: ->(tui, tab) { tab.stop_filtering }, refresh: true},
39
+ {code: "esc", action: ->(tui, tab) { tab.stop_and_clear_filtering }, refresh: true}
40
+ ]
41
+ }.freeze
42
+
43
+ # Returns an array of symbols for functionality which this tab implements
44
+ def features
45
+ []
46
+ end
47
+
48
+ def controls
49
+ GLOBAL + SHARED.slice(*features).values.flatten
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,53 @@
1
+ module Sidekiq
2
+ class TUI
3
+ module Filtering
4
+ def filtering?
5
+ @data[:filtering]
6
+ end
7
+
8
+ def current_filter
9
+ @data[:filter]
10
+ end
11
+
12
+ def start_filtering
13
+ @data[:filtering] = true
14
+ @data[:filter] = ""
15
+ end
16
+
17
+ def stop_filtering
18
+ return unless @data[:filtering]
19
+
20
+ @data[:filtering] = false
21
+ @data[:selected] = []
22
+ end
23
+
24
+ def stop_and_clear_filtering
25
+ return unless @data[:filtering]
26
+
27
+ @data[:filtering] = false
28
+ @data[:filter] = nil
29
+ @data[:selected] = []
30
+ on_filter_change
31
+ end
32
+
33
+ def remove_last_char_from_filter
34
+ return unless @data[:filtering]
35
+
36
+ @data[:filter] = @data[:filter].empty? ? "" : @data[:filter][0..-2]
37
+ on_filter_change
38
+ end
39
+
40
+ def append_to_filter(string)
41
+ return unless @data[:filtering]
42
+
43
+ @data[:filter] += string
44
+ @data[:selected] = []
45
+ on_filter_change
46
+ end
47
+
48
+ def on_filter_change
49
+ # callback for subclasses
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,187 @@
1
+ module Sidekiq
2
+ class TUI
3
+ class BaseTab
4
+ include Controls
5
+
6
+ attr_reader :name
7
+ attr_reader :data
8
+
9
+ def initialize(parent)
10
+ @parent = parent
11
+ @name = self.class.name.split("::").last
12
+ reset_data
13
+ end
14
+
15
+ def t(*)
16
+ @parent.t(*)
17
+ end
18
+
19
+ def reset_data
20
+ @data = {selected: [], selected_row_index: 0}
21
+ end
22
+
23
+ def error
24
+ @data[:error]
25
+ end
26
+
27
+ def error=(e)
28
+ @data[:error] = e
29
+ end
30
+
31
+ def selected?(entry)
32
+ @data[:selected].index(entry.id)
33
+ end
34
+
35
+ def filtering?
36
+ false
37
+ end
38
+
39
+ def each_selection(unselect: true, &)
40
+ sel = @data[:selected]
41
+ finished = []
42
+ if !sel.empty?
43
+ sel.each do |id|
44
+ yield id
45
+ # When processing multiple items in bulk, we want to unselect
46
+ # each row if its operation succeeds so our UI will not
47
+ # re-process rows 1-3 if row 4 fails.
48
+ finished << id
49
+ end
50
+ else
51
+ ids = @data.dig(:table, :row_ids)
52
+ return if !ids || ids.empty?
53
+ yield ids[@data[:selected_row_index]]
54
+ end
55
+ ensure
56
+ @data[:selected] = sel - finished if unselect
57
+ end
58
+
59
+ # Navigate the row selection up or down in the current tab's table.
60
+ # @param direction [Symbol] :up or :down
61
+ def navigate_row(direction)
62
+ ids = @data.dig(:table, :row_ids)
63
+ return if !ids || ids.empty?
64
+
65
+ index_change = (direction == :down) ? 1 : -1
66
+ @data[:selected_row_index] = (@data[:selected_row_index] + index_change) % ids.count
67
+ end
68
+
69
+ def prev_page
70
+ opts = @data.dig(:table, :pager)
71
+ return unless opts
72
+ return if opts.page < 2
73
+
74
+ @data[:table][:pager] = Sidekiq::TUI::PageOptions.new(opts.page - 1, opts.size)
75
+ end
76
+
77
+ def next_page
78
+ np = @data.dig(:table, :next_page)
79
+ return unless np
80
+ opts = @data.dig(:table, :pager)
81
+ return unless opts
82
+
83
+ @data[:table][:pager] = Sidekiq::TUI::PageOptions.new(np, opts.size)
84
+ end
85
+
86
+ def toggle_select(which = :current)
87
+ sel = @data[:selected]
88
+ # log(which, sel)
89
+ if which == :current
90
+ x = @data[:table][:row_ids][@data[:selected_row_index]]
91
+ if sel.index(x)
92
+ # already checked, uncheck it
93
+ sel.delete(x)
94
+ else
95
+ sel << x
96
+ end
97
+ elsif sel.empty?
98
+ @data[:selected] = @data[:table][:row_ids]
99
+ else
100
+ sel.clear
101
+ end
102
+ end
103
+
104
+ def refresh_data_for_stats
105
+ stats = Sidekiq::Stats.new
106
+ @data[:stats] = {
107
+ processed: stats.processed,
108
+ failed: stats.failed,
109
+ busy: stats.workers_size,
110
+ enqueued: stats.enqueued,
111
+ retries: stats.retry_size,
112
+ scheduled: stats.scheduled_size,
113
+ dead: stats.dead_size
114
+ }
115
+ end
116
+
117
+ def render_table(tui, frame, area)
118
+ page = @data.dig(:table, :current_page) || 1
119
+ rows = @data.dig(:table, :rows) || []
120
+ total = @data.dig(:table, :total) || 0
121
+ footer = ["", "Page: #{page}", "Count: #{rows.size}", "Total: #{total}"]
122
+ footer << "Selected: #{@data[:selected].size}" unless @data[:selected].empty?
123
+
124
+ defaults = {
125
+ title: "TableName",
126
+ footer: footer
127
+ }
128
+ if features.include?(:selectable)
129
+ defaults.merge!({
130
+ highlight_symbol: "➡️",
131
+ selected_row: @data[:selected_row_index],
132
+ row_highlight_style: tui.style(fg: :white, bg: :blue)
133
+ })
134
+ end
135
+ hash = defaults.merge(yield)
136
+ hash[:block] ||= tui.block(title: hash.delete(:title), borders: :all)
137
+ table = tui.table(**hash)
138
+ frame.render_widget(table, area)
139
+ end
140
+
141
+ def render_stats_section(tui, frame, area)
142
+ stats = @data[:stats]
143
+
144
+ keys = ["Processed", "Failed", "Busy", "Enqueued", "Retries", "Scheduled", "Dead"]
145
+ values = [
146
+ stats[:processed],
147
+ stats[:failed],
148
+ stats[:busy],
149
+ stats[:enqueued],
150
+ stats[:retries],
151
+ stats[:scheduled],
152
+ stats[:dead]
153
+ ]
154
+
155
+ # Format keys and values with spacing
156
+ keys_line = keys.map { |k| t(k).to_s.ljust(12) }.join(" ")
157
+ values_line = values.map { |v| v.to_s.ljust(12) }.join(" ")
158
+
159
+ frame.render_widget(
160
+ tui.paragraph(
161
+ text: [keys_line, values_line],
162
+ block: tui.block(title: "Statistics", borders: [:all])
163
+ ),
164
+ area
165
+ )
166
+ end
167
+
168
+ # TODO Implement I18n delimiter
169
+ def number_with_delimiter(number, options = {})
170
+ precision = options[:precision] || 0
171
+ number.round(precision)
172
+ end
173
+
174
+ def format_memory(rss_kb)
175
+ return "0" if rss_kb.nil? || rss_kb == 0
176
+
177
+ if rss_kb < 100_000
178
+ "#{number_with_delimiter(rss_kb)} KB"
179
+ elsif rss_kb < 10_000_000
180
+ "#{number_with_delimiter((rss_kb / 1024.0).to_i)} MB"
181
+ else
182
+ "#{number_with_delimiter(rss_kb / (1024.0 * 1024.0), precision: 1)} GB"
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end