sidekiq-tasks 1.1.1 → 1.2.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/.rubocop.yml +4 -0
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +96 -0
  5. data/docs/custom_storage.md +44 -11
  6. data/lib/sidekiq/tasks/config.rb +28 -9
  7. data/lib/sidekiq/tasks/job.rb +3 -2
  8. data/lib/sidekiq/tasks/magic_comments/comment.rb +28 -0
  9. data/lib/sidekiq/tasks/magic_comments/comments.rb +31 -0
  10. data/lib/sidekiq/tasks/magic_comments/handlers/base.rb +26 -0
  11. data/lib/sidekiq/tasks/magic_comments/handlers/disable.rb +17 -0
  12. data/lib/sidekiq/tasks/magic_comments/handlers/dry_run.rb +17 -0
  13. data/lib/sidekiq/tasks/magic_comments/handlers/enable.rb +17 -0
  14. data/lib/sidekiq/tasks/magic_comments/handlers/sidekiq_options.rb +32 -0
  15. data/lib/sidekiq/tasks/magic_comments/registration.rb +11 -0
  16. data/lib/sidekiq/tasks/magic_comments/registry.rb +19 -0
  17. data/lib/sidekiq/tasks/magic_comments/scanner.rb +83 -0
  18. data/lib/sidekiq/tasks/magic_comments.rb +20 -0
  19. data/lib/sidekiq/tasks/run.rb +161 -0
  20. data/lib/sidekiq/tasks/sidekiq_options_validator.rb +25 -0
  21. data/lib/sidekiq/tasks/storage/base.rb +22 -0
  22. data/lib/sidekiq/tasks/storage/redis.rb +47 -26
  23. data/lib/sidekiq/tasks/strategies/base.rb +5 -2
  24. data/lib/sidekiq/tasks/strategies/rake_task.rb +26 -2
  25. data/lib/sidekiq/tasks/strategies/rules/disable_with_comment.rb +2 -2
  26. data/lib/sidekiq/tasks/strategies/rules/enable_with_comment.rb +8 -56
  27. data/lib/sidekiq/tasks/task.rb +24 -7
  28. data/lib/sidekiq/tasks/task_metadata.rb +18 -2
  29. data/lib/sidekiq/tasks/version.rb +1 -1
  30. data/lib/sidekiq/tasks/web/extension.rb +2 -1
  31. data/lib/sidekiq/tasks/web/helpers/tag_helper.rb +1 -1
  32. data/lib/sidekiq/tasks/web/helpers/task_helper.rb +19 -0
  33. data/lib/sidekiq/tasks.rb +18 -1
  34. data/web/assets/tasks/css/components/alerts.css +25 -0
  35. data/web/assets/tasks/css/components/report.css +70 -0
  36. data/web/assets/tasks/css/ext.css +2 -0
  37. data/web/locales/en.yml +10 -0
  38. data/web/locales/fr.yml +10 -0
  39. data/web/views/task.erb +87 -3
  40. metadata +31 -1
@@ -0,0 +1,161 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ # The context of a task execution, available from the task code through `Sidekiq::Tasks.current_run`.
4
+ class Run
5
+ THREAD_KEY = :sidekiq_tasks_run
6
+
7
+ attr_reader :report
8
+
9
+ # Outside of a Sidekiq::Tasks job (e.g. `rake` in a terminal), dry run is disabled
10
+ # and the report is printed on exit.
11
+ def self.current
12
+ Thread.current[THREAD_KEY] || standalone
13
+ end
14
+
15
+ def self.with(run)
16
+ previous_run = Thread.current[THREAD_KEY]
17
+ Thread.current[THREAD_KEY] = run
18
+ yield
19
+ ensure
20
+ Thread.current[THREAD_KEY] = previous_run
21
+ end
22
+
23
+ def self.standalone
24
+ @_standalone ||= new.tap do |run|
25
+ at_exit { puts JSON.pretty_generate(run.report.to_h) unless run.report.empty? }
26
+ end
27
+ end
28
+ private_class_method :standalone
29
+
30
+ def initialize(dry_run: false, report_max_bytes: Sidekiq::Tasks.config.report_max_bytes)
31
+ @dry_run = dry_run
32
+ @report = Report.new(max_bytes: report_max_bytes)
33
+ end
34
+
35
+ def dry_run?
36
+ @dry_run
37
+ end
38
+ end
39
+
40
+ # A structured execution report, stored in the task history and displayed in the web interface.
41
+ #
42
+ # The report size is bounded by `max_bytes`, see Sidekiq::Tasks::Storage::Base#report_max_bytes:
43
+ # - the serialized report never exceeds `max_bytes`, the last items of the longest lists are dropped
44
+ # and the counts remain exact,
45
+ # - while the task runs, items stop being kept beyond MEMORY_FACTOR times that size, protecting worker memory,
46
+ # - strings are truncated to MAX_STRING_LENGTH, so that a single long message does not fill the whole report,
47
+ # - at most MAX_KEYS values, lists and item keys, guarding against keys built from the data.
48
+ class Report
49
+ MAX_KEYS = 50
50
+ MAX_STRING_LENGTH = 255
51
+ MEMORY_FACTOR = 4
52
+
53
+ def initialize(max_bytes: Sidekiq::Tasks.config.report_max_bytes)
54
+ @max_bytes = max_bytes
55
+ @kept_bytes = 0
56
+ @values = {}
57
+ @lists = {}
58
+ @truncated = false
59
+ end
60
+
61
+ def []=(key, value)
62
+ key = sanitize_key(key)
63
+ @values[key] = sanitize_scalar(value) if accept_key?(@values, key)
64
+ end
65
+
66
+ def increment(key, by = 1)
67
+ key = sanitize_key(key)
68
+ @values[key] = sanitize_scalar((@values[key] || 0) + by) if accept_key?(@values, key)
69
+ end
70
+
71
+ # Adds an item (a scalar or a flat Hash) to a list.
72
+ def add(key, item)
73
+ key = sanitize_key(key)
74
+ return unless accept_key?(@lists, key)
75
+
76
+ list = @lists[key] ||= {"items" => [], "count" => 0}
77
+ list["count"] += 1
78
+ keep_item(list, sanitize_item(item))
79
+ end
80
+
81
+ def empty?
82
+ @values.empty? && @lists.empty?
83
+ end
84
+
85
+ # @return [Hash] The report, serializable as JSON and at most `max_bytes` long.
86
+ def to_h
87
+ lists = @lists.transform_values { |list| list.merge("items" => list["items"].dup) }
88
+ report = {"values" => @values, "lists" => lists}
89
+ report["truncated"] = true if @truncated
90
+ return report if Sidekiq.dump_json(report).bytesize <= @max_bytes
91
+
92
+ fit(report.merge("truncated" => true))
93
+ end
94
+
95
+ private
96
+
97
+ # Falls back to an empty report when values alone exceed max_bytes.
98
+ def fit(report)
99
+ drop_items(report, Sidekiq.dump_json(report).bytesize)
100
+ return report if Sidekiq.dump_json(report).bytesize <= @max_bytes
101
+
102
+ {"values" => {}, "lists" => {}, "truncated" => true}
103
+ end
104
+
105
+ # Sizes are tracked incrementally so that the report is not serialized again for each dropped item.
106
+ def drop_items(report, size)
107
+ lists = report["lists"].values
108
+
109
+ while size > @max_bytes
110
+ longest_list = lists.max_by { |list| list["items"].size }
111
+ break if longest_list.nil? || longest_list["items"].empty?
112
+
113
+ size -= Sidekiq.dump_json(longest_list["items"].pop).bytesize + 1
114
+ end
115
+ end
116
+
117
+ # Items are kept with a margin over max_bytes so that to_h can still balance the lists,
118
+ # but never beyond it, so that the worker memory stays bounded.
119
+ def keep_item(list, item)
120
+ if @kept_bytes > @max_bytes * MEMORY_FACTOR
121
+ @truncated = true
122
+ return
123
+ end
124
+
125
+ @kept_bytes += Sidekiq.dump_json(item).bytesize
126
+ list["items"] << item
127
+ end
128
+
129
+ def accept_key?(hash, key)
130
+ return true if hash.key?(key) || hash.size < MAX_KEYS
131
+
132
+ @truncated = true
133
+ false
134
+ end
135
+
136
+ def sanitize_key(key)
137
+ truncate(key.to_s)
138
+ end
139
+
140
+ def sanitize_item(item)
141
+ return sanitize_scalar(item) unless item.is_a?(Hash)
142
+
143
+ item.first(MAX_KEYS).to_h { |key, value| [sanitize_key(key), sanitize_scalar(value)] }
144
+ end
145
+
146
+ def sanitize_scalar(value)
147
+ case value
148
+ when Float then value.finite? ? value : value.to_s
149
+ when Numeric, TrueClass, FalseClass, NilClass then value
150
+ else truncate(value.to_s)
151
+ end
152
+ end
153
+
154
+ def truncate(string)
155
+ return string if string.length <= MAX_STRING_LENGTH
156
+
157
+ "#{string[0...(MAX_STRING_LENGTH - 3)]}..."
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,25 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module SidekiqOptionsValidator
4
+ KEYS = {
5
+ queue: [String],
6
+ retry: [NilClass, TrueClass, FalseClass, Integer],
7
+ retry_for: [NilClass, Integer, Float],
8
+ dead: [NilClass, TrueClass, FalseClass],
9
+ backtrace: [NilClass, TrueClass, FalseClass, Integer],
10
+ pool: [NilClass, String],
11
+ tags: [NilClass, Array],
12
+ }.freeze
13
+
14
+ def self.validate!(options, allow_partial: false)
15
+ Sidekiq::Tasks::Validations.validate_class!(options, [Hash], "sidekiq_options")
16
+
17
+ KEYS.each do |key, classes|
18
+ next if allow_partial && !options.key?(key)
19
+
20
+ Sidekiq::Tasks::Validations.validate_class!(options[key], classes, key)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -60,6 +60,28 @@ module Sidekiq
60
60
  raise NotImplementedError, "Storage must implement #store_execution_error"
61
61
  end
62
62
 
63
+ # Marks a history entry as a dry run.
64
+ # Optional: storages that don't implement it simply don't display the dry run badge.
65
+ #
66
+ # @param jid [String] The Sidekiq job ID.
67
+ def store_dry_run(_jid)
68
+ end
69
+
70
+ # Stores the execution report for a specific history entry.
71
+ # Optional: storages that don't implement it simply don't display reports.
72
+ #
73
+ # @param jid [String] The Sidekiq job ID.
74
+ # @param report [Hash] The report, see Sidekiq::Tasks::Report#to_h.
75
+ def store_execution_report(_jid, _report)
76
+ end
77
+
78
+ # The maximum size of a stored report, lowered by storages with their own memory constraints.
79
+ #
80
+ # @return [Integer]
81
+ def report_max_bytes
82
+ Sidekiq::Tasks.config.report_max_bytes
83
+ end
84
+
63
85
  protected
64
86
 
65
87
  def truncate_message(message, max_length)
@@ -3,6 +3,10 @@ module Sidekiq
3
3
  module Storage
4
4
  class Redis < Base
5
5
  JID_PREFIX = "task".freeze
6
+
7
+ # Maximum size of the reports stored in the history of a task, whatever the history_limit.
8
+ TASK_REPORTS_BUDGET = 10 * 1024 * 1024
9
+
6
10
  def jid_key
7
11
  "#{JID_PREFIX}:#{task_name}"
8
12
  end
@@ -11,22 +15,20 @@ module Sidekiq
11
15
  "#{jid_key}:history"
12
16
  end
13
17
 
18
+ def execution_key(jid)
19
+ "#{jid_key}:execution:#{jid}"
20
+ end
21
+
14
22
  def last_enqueue_at
15
23
  stored_time("last_enqueue_at")
16
24
  end
17
25
 
18
26
  def history
19
- raw_entries = Sidekiq.redis { |conn| conn.lrange(history_key, 0, -1) }
27
+ entries = Sidekiq.redis { |conn| conn.lrange(history_key, 0, -1) }.map { |raw| Sidekiq.load_json(raw) }
20
28
 
21
- return [] unless raw_entries
29
+ return [] if entries.empty?
22
30
 
23
- raw_entries.map do |raw|
24
- entry = Sidekiq.load_json(raw)
25
- ["enqueued_at", "executed_at", "finished_at"].each do |key|
26
- entry[key] = Time.at(entry[key]) if entry[key]
27
- end
28
- entry
29
- end
31
+ entries.zip(executions(entries)).map { |entry, execution| parse_entry(entry.merge(execution)) }
30
32
  end
31
33
 
32
34
  def store_history(jid, task_args, time, user: nil)
@@ -34,6 +36,7 @@ module Sidekiq
34
36
  task_trace = {jid: jid, task_name: task_name, args: task_args, enqueued_at: time.to_f}
35
37
  task_trace[:user] = user if user
36
38
  conn.lpush(history_key, Sidekiq.dump_json(task_trace))
39
+ delete_executions(conn, conn.lrange(history_key, history_limit, -1))
37
40
  conn.ltrim(history_key, 0, history_limit - 1)
38
41
  end
39
42
  end
@@ -45,16 +48,24 @@ module Sidekiq
45
48
  end
46
49
 
47
50
  def store_execution(jid, time_key)
48
- update_history_entry(jid) do |entry|
49
- entry.merge(time_key => Time.now.to_f)
50
- end
51
+ Sidekiq.redis { |conn| conn.hset(execution_key(jid), time_key, Time.now.to_f) }
51
52
  end
52
53
 
53
54
  def store_execution_error(jid, error)
54
- update_history_entry(jid) do |entry|
55
- error_message = truncate_message("#{error.class}: #{error.message}", ERROR_MESSAGE_MAX_LENGTH)
56
- entry.merge("error" => error_message)
57
- end
55
+ error_message = truncate_message("#{error.class}: #{error.message}", ERROR_MESSAGE_MAX_LENGTH)
56
+ Sidekiq.redis { |conn| conn.hset(execution_key(jid), "error", error_message) }
57
+ end
58
+
59
+ def store_dry_run(jid)
60
+ Sidekiq.redis { |conn| conn.hset(execution_key(jid), "dry_run", Sidekiq.dump_json(true)) }
61
+ end
62
+
63
+ def store_execution_report(jid, report)
64
+ Sidekiq.redis { |conn| conn.hset(execution_key(jid), "report", Sidekiq.dump_json(report)) }
65
+ end
66
+
67
+ def report_max_bytes
68
+ [super, TASK_REPORTS_BUDGET / history_limit].min
58
69
  end
59
70
 
60
71
  private
@@ -69,20 +80,30 @@ module Sidekiq
69
80
  [nil, ""].include?(timestamp) ? nil : Time.at(timestamp.to_f)
70
81
  end
71
82
 
72
- def update_history_entry(jid)
83
+ def executions(entries)
73
84
  Sidekiq.redis do |conn|
74
- entries = conn.lrange(history_key, 0, -1)
75
-
76
- entries.each_with_index do |raw, index|
77
- entry = Sidekiq.load_json(raw)
78
- next unless entry["jid"] == jid
79
-
80
- updated_entry = yield(entry)
81
- conn.lset(history_key, index, Sidekiq.dump_json(updated_entry))
82
- break
85
+ conn.pipelined do |pipeline|
86
+ entries.each { |entry| pipeline.hgetall(execution_key(entry["jid"])) }
83
87
  end
84
88
  end
85
89
  end
90
+
91
+ def parse_entry(entry)
92
+ ["enqueued_at", "executed_at", "finished_at"].each do |key|
93
+ entry[key] = Time.at(entry[key].to_f) if entry[key]
94
+ end
95
+ ["dry_run", "report"].each do |key|
96
+ entry[key] = Sidekiq.load_json(entry[key]) if entry[key]
97
+ end
98
+
99
+ entry
100
+ end
101
+
102
+ def delete_executions(conn, raw_entries)
103
+ return if raw_entries.empty?
104
+
105
+ conn.del(*raw_entries.map { |raw| execution_key(Sidekiq.load_json(raw)["jid"]) })
106
+ end
86
107
  end
87
108
  end
88
109
  end
@@ -51,9 +51,12 @@ module Sidekiq
51
51
  #
52
52
  # @param name [String] The name of the task to enqueue.
53
53
  # @param params [Hash] Parameters to pass to the task.
54
+ # @param sidekiq_options [Hash] Per-task Sidekiq options that override class-level defaults.
55
+ # @param dry_run [Boolean] Whether the task must be executed in dry run mode.
54
56
  # @return [String] The JID of the sidekiq job that will execute the task.
55
- def enqueue_task(name, params = {})
56
- Sidekiq::Tasks::Job.perform_async(name, params.to_json)
57
+ def enqueue_task(name, params = {}, sidekiq_options: {}, dry_run: false)
58
+ job = sidekiq_options.empty? ? Sidekiq::Tasks::Job : Sidekiq::Tasks::Job.set(sidekiq_options)
59
+ job.perform_async(name, params.to_json, dry_run)
57
60
  end
58
61
 
59
62
  # Returns all the tasks that should be executed.
@@ -2,6 +2,11 @@ module Sidekiq
2
2
  module Tasks
3
3
  module Strategies
4
4
  class RakeTask < Base
5
+ def initialize(rules: [], scanner: Sidekiq::Tasks::MagicComments::Scanner.new)
6
+ super(rules: rules)
7
+ @scanner = scanner
8
+ end
9
+
5
10
  def load_tasks
6
11
  Rake::TaskManager.record_task_metadata = true
7
12
  Rake.application.load_rakefile
@@ -12,14 +17,33 @@ module Sidekiq
12
17
  Sidekiq::Tasks::TaskMetadata.new(
13
18
  name: task.name,
14
19
  desc: task.full_comment,
15
- file: task.locations.first.split(":").first,
16
- args: task.arg_names
20
+ file: task_file(task),
21
+ args: task.arg_names,
22
+ **magic_comment_options(task)
17
23
  )
24
+ rescue Sidekiq::Tasks::ArgumentError => e
25
+ Sidekiq::Tasks::TaskMetadata.new(name: task.name, file: task_file(task), error: e.message)
18
26
  end
19
27
 
20
28
  def execute_task(name, args = nil)
21
29
  Rake::Task[name].execute(args)
22
30
  end
31
+
32
+ private
33
+
34
+ def task_file(task)
35
+ task.locations.first&.split(":")&.first
36
+ end
37
+
38
+ def magic_comment_options(task)
39
+ comments = @scanner.scan(task)
40
+ handlers = Sidekiq::Tasks::MagicComments::Handlers
41
+
42
+ {
43
+ sidekiq_options: comments.fetch(handlers::SidekiqOptions.name_token, default: {}),
44
+ dry_run: comments.any?(handlers::DryRun.name_token),
45
+ }
46
+ end
23
47
  end
24
48
  end
25
49
  end
@@ -9,8 +9,8 @@ module Sidekiq
9
9
 
10
10
  protected
11
11
 
12
- def magic_comment_regex
13
- /sidekiq-tasks:disable/
12
+ def magic_comment_token
13
+ Sidekiq::Tasks::MagicComments::Handlers::Disable.name_token
14
14
  end
15
15
  end
16
16
  end
@@ -3,67 +3,19 @@ module Sidekiq
3
3
  module Strategies
4
4
  module Rules
5
5
  class EnableWithComment < Base
6
- def respected?(task)
7
- file, line_number = task.locations.first.split(":")
8
- line_number = line_number.to_i
9
-
10
- lines = read_file_lines(file)
11
-
12
- return false if lines.nil?
13
-
14
- return true if task_has_magic_comment?(lines, line_number)
15
- return true if desc_has_magic_comment?(task.full_comment, lines, line_number)
16
-
17
- namespace_line_index = find_namespace_line_index(lines, task)
18
- return false unless namespace_line_index
19
-
20
- namespace_has_magic_comment?(lines, namespace_line_index)
6
+ def initialize(scanner: Sidekiq::Tasks::MagicComments::Scanner.new)
7
+ super()
8
+ @scanner = scanner
21
9
  end
22
10
 
23
- protected
24
-
25
- def magic_comment_regex
26
- /sidekiq-tasks:enable/
27
- end
28
-
29
- private
30
-
31
- def read_file_lines(file)
32
- File.read(file).split("\n")
33
- rescue Errno::ENOENT
34
- raise ArgumentError, "File '#{file}' not found"
35
- end
36
-
37
- def desc_has_magic_comment?(desc, lines, task_line)
38
- return false unless desc
39
-
40
- desc_line_index = lines[0...task_line].rindex { |line| line.strip.match?(/^desc\b/) }
41
-
42
- return false unless desc_line_index
43
-
44
- comment_line = lines[desc_line_index - 1]&.strip
45
- !!(comment_line =~ magic_comment_regex)
46
- end
47
-
48
- def task_has_magic_comment?(lines, task_line)
49
- context_range = (task_line - 3..task_line).to_a.select { |i| i >= 0 }
50
- context_range.reverse.any? do |i|
51
- lines[i]&.strip&.match?(magic_comment_regex)
52
- end
53
- end
54
-
55
- def find_namespace_line_index(lines, task)
56
- namespace = namespace_name(task)
57
- lines.find_index { |line| line.strip.match?(/^namespace\s+:#{Regexp.escape(namespace)}/) }
11
+ def respected?(task)
12
+ @scanner.scan(task).any?(magic_comment_token)
58
13
  end
59
14
 
60
- def namespace_has_magic_comment?(lines, namespace_line_index)
61
- comment_line = lines[namespace_line_index - 1]&.strip
62
- comment_line&.match?(magic_comment_regex)
63
- end
15
+ protected
64
16
 
65
- def namespace_name(task)
66
- task.name.split(":").first
17
+ def magic_comment_token
18
+ Sidekiq::Tasks::MagicComments::Handlers::Enable.name_token
67
19
  end
68
20
  end
69
21
  end
@@ -6,8 +6,9 @@ module Sidekiq
6
6
  extend Forwardable
7
7
  include Sidekiq::Tasks::Validations
8
8
 
9
- def_delegators :metadata, :name, :desc, :file, :args
9
+ def_delegators :metadata, :name, :desc, :file, :args, :sidekiq_options, :error, :error?
10
10
  def_delegators :storage, :last_enqueue_at, :history
11
+ def_delegator :metadata, :dry_run?, :dry_run_supported?
11
12
 
12
13
  attr_reader :metadata, :strategy
13
14
 
@@ -22,29 +23,45 @@ module Sidekiq
22
23
  validate_class!(strategy, [Sidekiq::Tasks::Strategies::Base], "strategy")
23
24
  end
24
25
 
25
- def enqueue(params = {}, user: nil)
26
- jid = strategy.enqueue_task(name, params)
26
+ def enqueue(params = {}, user: nil, dry_run: false)
27
+ raise Sidekiq::Tasks::ArgumentError, "cannot enqueue broken task '#{name}': #{error}" if error?
28
+
29
+ enqueue_options = {}
30
+ enqueue_options[:sidekiq_options] = sidekiq_options if sidekiq_options.any?
31
+ enqueue_options[:dry_run] = true if dry_run
32
+
33
+ jid = strategy.enqueue_task(name, params, **enqueue_options)
27
34
 
28
35
  storage.store_enqueue(jid, params, user: user)
36
+ storage.store_dry_run(jid) if dry_run
29
37
  end
30
38
 
31
- def execute(params = {}, jid: nil)
39
+ def execute(params = {}, jid: nil, dry_run: false)
40
+ run = Sidekiq::Tasks::Run.new(dry_run: dry_run, report_max_bytes: storage.report_max_bytes)
32
41
  storage.store_execution(jid, "executed_at")
33
42
 
34
43
  begin
35
- strategy.execute_task(name, params)
44
+ Sidekiq::Tasks::Run.with(run) { strategy.execute_task(name, params) }
36
45
  rescue StandardError, SystemExit => e
37
- storage.store_execution(jid, "finished_at")
46
+ finish_execution(jid, run)
38
47
  storage.store_execution_error(jid, e)
39
48
  raise
40
49
  end
41
50
 
42
- storage.store_execution(jid, "finished_at")
51
+ finish_execution(jid, run)
43
52
  end
44
53
 
45
54
  def storage
46
55
  @_storage ||= Sidekiq::Tasks.config.storage.new(name, history_limit: Sidekiq::Tasks.config.history_limit)
47
56
  end
57
+
58
+ private
59
+
60
+ # The report is stored before finished_at so that it is displayed as soon as the execution appears finished.
61
+ def finish_execution(jid, run)
62
+ storage.store_execution_report(jid, run.report.to_h) unless run.report.empty?
63
+ storage.store_execution(jid, "finished_at")
64
+ end
48
65
  end
49
66
  end
50
67
  end
@@ -3,24 +3,40 @@ module Sidekiq
3
3
  class TaskMetadata
4
4
  include Sidekiq::Tasks::Validations
5
5
 
6
- attr_reader :name, :desc, :file, :args
6
+ attr_reader :name, :desc, :file, :args, :sidekiq_options, :error, :dry_run
7
7
 
8
- def initialize(name:, file:, desc: "", args: [])
8
+ def initialize(name:, file:, desc: "", args: [], sidekiq_options: {}, error: nil, dry_run: false)
9
9
  @name = name
10
10
  @file = file
11
11
  @desc = desc
12
12
  @args = args
13
+ @sidekiq_options = sidekiq_options
14
+ @error = error
15
+ @dry_run = dry_run
13
16
 
14
17
  validate_params!
15
18
  end
16
19
 
20
+ def error?
21
+ !@error.nil?
22
+ end
23
+
24
+ def dry_run?
25
+ @dry_run
26
+ end
27
+
17
28
  private
18
29
 
19
30
  def validate_params!
20
31
  validate_class!(name, [String, Symbol], "name")
21
32
  validate_class!(file, [String, NilClass], "file")
33
+ validate_class!(error, [String, NilClass], "error")
34
+ return if error?
35
+
22
36
  validate_class!(desc, [String, NilClass], "desc")
23
37
  validate_array_classes!(args, [String, Symbol], "args")
38
+ validate_class!(sidekiq_options, [Hash], "sidekiq_options")
39
+ validate_class!(dry_run, [TrueClass, FalseClass], "dry_run")
24
40
  end
25
41
  end
26
42
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sidekiq
4
4
  module Tasks
5
- VERSION = "1.1.1"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  end
@@ -62,7 +62,8 @@ module Sidekiq
62
62
  args = Sidekiq::Tasks::Web::Params.new(task, fetch_param("args")).permit!
63
63
 
64
64
  current_user = Sidekiq::Tasks.config.current_user&.call(env)
65
- task.enqueue(args, user: current_user)
65
+ dry_run = task.dry_run_supported? && fetch_param("dry_run") == "1"
66
+ task.enqueue(args, user: current_user, dry_run: dry_run)
66
67
 
67
68
  redirect(task_url(root_path, task))
68
69
  rescue Sidekiq::Tasks::ArgumentError => e
@@ -6,7 +6,7 @@ module Sidekiq
6
6
  extend self
7
7
 
8
8
  def build_tag(tag, content = nil, **attributes, &block)
9
- attr_string = attributes.map { |key, value| "#{key}=\"#{value}\"" }.join(" ")
9
+ attr_string = attributes.map { |key, value| "#{key}=\"#{ERB::Util.html_escape(value)}\"" }.join(" ")
10
10
  attr_string = " #{attr_string}" unless attr_string.empty?
11
11
 
12
12
  "<#{tag}#{attr_string}>#{block&.call || content}</#{tag}>"
@@ -36,6 +36,13 @@ module Sidekiq
36
36
  end
37
37
  end
38
38
 
39
+ # Returns the table columns for a report list: the keys of Hash items, or nil for scalar items.
40
+ def report_list_columns(items)
41
+ return nil unless items.any? && items.all?(Hash)
42
+
43
+ items.flat_map(&:keys).uniq
44
+ end
45
+
39
46
  def format_task_duration(start_time, end_time)
40
47
  return "-" unless start_time && end_time
41
48
 
@@ -48,6 +55,18 @@ module Sidekiq
48
55
  "#{[duration_in_milliseconds, 1].max}ms"
49
56
  end
50
57
  end
58
+
59
+ def format_task_sidekiq_options(task)
60
+ task.sidekiq_options.map { |key, value| "#{key}: #{format_sidekiq_option_value(value)}" }.join(", ")
61
+ end
62
+
63
+ def format_sidekiq_option_value(value)
64
+ case value
65
+ when Array then "[#{value.join(", ")}]"
66
+ when nil then "-"
67
+ else value.to_s
68
+ end
69
+ end
51
70
  end
52
71
  end
53
72
  end