joblin 0.1.11 → 0.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/models/joblin/background_task/api_access.rb +15 -2
  3. data/app/models/joblin/background_task/attachments.rb +2 -2
  4. data/app/models/joblin/background_task/retention_policy.rb +12 -4
  5. data/app/models/joblin/background_task.rb +8 -1
  6. data/db/migrate/20260709120000_add_indexes_to_joblin_background_tasks.rb +10 -0
  7. data/lib/joblin/batching/batch.rb +60 -33
  8. data/lib/joblin/batching/callback.rb +9 -1
  9. data/lib/joblin/batching/chain_builder.rb +3 -2
  10. data/lib/joblin/batching/compat/sidekiq/web/views/_batches_table.erb +2 -2
  11. data/lib/joblin/batching/compat/sidekiq/web/views/_jobs_table.erb +3 -3
  12. data/lib/joblin/batching/compat/sidekiq/web/views/batch.erb +5 -5
  13. data/lib/joblin/batching/compat/sidekiq/web/views/pool.erb +13 -13
  14. data/lib/joblin/batching/compat/sidekiq/web/views/pools.erb +3 -3
  15. data/lib/joblin/batching/compat/sidekiq/web.rb +17 -1
  16. data/lib/joblin/batching/context_hash.rb +1 -1
  17. data/lib/joblin/batching/jobs/managed_batch_job.rb +8 -2
  18. data/lib/joblin/batching/pool.rb +12 -3
  19. data/lib/joblin/batching/pool_refill.lua +2 -1
  20. data/lib/joblin/batching/schedule_callback.lua +6 -0
  21. data/lib/joblin/lazy_access.rb +108 -19
  22. data/lib/joblin/uniqueness/compat/active_job.rb +1 -1
  23. data/lib/joblin/uniqueness/job_uniqueness.rb +2 -0
  24. data/lib/joblin/uniqueness/lock_context.rb +4 -0
  25. data/lib/joblin/uniqueness/on_conflict/log.rb +1 -0
  26. data/lib/joblin/uniqueness/on_conflict/reject.rb +8 -1
  27. data/lib/joblin/uniqueness/strategy/until_expired.rb +1 -0
  28. data/lib/joblin/uniqueness/unique_job_common.rb +15 -7
  29. data/lib/joblin/version.rb +1 -1
  30. data/spec/internal/log/test.log +6619 -0
  31. data/spec/lazy_access_spec.rb +76 -0
  32. data/spec/models/api_access_spec.rb +57 -0
  33. data/spec/uniqueness/on_conflict/reject_spec.rb +42 -0
  34. data/spec/uniqueness/strategy/until_expired_spec.rb +7 -0
  35. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fee4e742fd313bae02c2d077066985c147749f207dfd211ac5adb29426a80d84
4
- data.tar.gz: d0d074e5a51d65ee15a63e23a549240014f2d09c882ce5632163ab4a9a444c7a
3
+ metadata.gz: e87b736e32f7b502592a72a08da939d0a2ef95302e96b491f8d258bcdafcfab1
4
+ data.tar.gz: 97df4d3166bb653f6362d11b600c91a805c4cdc368091690265ae2c686c136d2
5
5
  SHA512:
6
- metadata.gz: 8f91c842abc953c54b356d4fb58e69bfda58e30684ede90b71a7e5503d54c198a0ae823e5e84ee81c94d5af92d8260bb331deb5f4270a09313c695d8d2afbd99
7
- data.tar.gz: 8a3c1553aff7ac339d16e30931e5960aaee92047b4424b9a59e6b8d4f1ea6d38a5a8e3c8a3ad2aa999bc36dd694ff80ee579013c0047066de03d62baab24637a
6
+ metadata.gz: 352592cc3733c55879d51ac118c242184140d174f7873b632a6695a7536a641493a3515377a5b838bc1d27e22c9cb5092517b8d4096098b96cfc124561ea558e
7
+ data.tar.gz: a818e8053990c09106eb47a21ca8bd70aeea51a21a3d21f8a14de54bd6f03e7aa4931913d145b90db8a8c3907eb1bb78ae0dd4f25cc3e07450d120bead87cde8
@@ -19,6 +19,7 @@ module Joblin
19
19
  def allow_api_access!(&blk)
20
20
  include Mixin unless self < Mixin
21
21
  @api_access_allowed = true
22
+ BackgroundTask.api_accessible_types[name] = self if name
22
23
  api_access_rules(&blk) if blk
23
24
  end
24
25
 
@@ -27,6 +28,15 @@ module Joblin
27
28
  @api_access_allowed
28
29
  end
29
30
 
31
+ # Registry (name => class) of task types that opted into API access.
32
+ # build_from_api resolves params[:type] against this instead of
33
+ # constantizing arbitrary user input, so an unknown/hostile type can't
34
+ # autoload arbitrary constants.
35
+ def api_accessible_types
36
+ BackgroundTask.instance_variable_get(:@api_accessible_types) ||
37
+ BackgroundTask.instance_variable_set(:@api_accessible_types, {})
38
+ end
39
+
30
40
  def find_for_api(id)
31
41
  task = find(id)
32
42
  raise ActiveRecord::RecordNotFound unless task.api_access_allowed?
@@ -41,7 +51,7 @@ module Joblin
41
51
  params ||= controller_or_params
42
52
  end
43
53
 
44
- task_type = self == BackgroundTask ? params[:type].safe_constantize : self
54
+ task_type = self == BackgroundTask ? api_accessible_types[params[:type].to_s] : self
45
55
  raise ActiveRecord::RecordNotFound unless task_type && task_type <= BackgroundTask
46
56
 
47
57
  task = task_type.new
@@ -68,7 +78,10 @@ module Joblin
68
78
  raise ActiveRecord::RecordInvalid.new(task) unless task.api_access_allowed?
69
79
 
70
80
  val_errors = task.api_validate_options
71
- raise ActiveRecord::ValidationError.new(val_errors) unless val_errors.empty?
81
+ unless val_errors.empty?
82
+ val_errors.each { |msg| task.errors.add(:base, msg) }
83
+ raise ActiveRecord::RecordInvalid.new(task)
84
+ end
72
85
 
73
86
  task
74
87
  end
@@ -38,9 +38,9 @@ module Joblin
38
38
  save_as = save_as || send(key).filename.to_s || key.to_s
39
39
  save_as = File.join(working_dir, save_as) unless Pathname.new(save_as).absolute?
40
40
 
41
- File.open(save_as, 'w') do |file|
41
+ File.open(save_as, 'wb') do |file|
42
42
  send(key).download do |chunk|
43
- file.write(chunk.force_encoding("UTF-8"))
43
+ file.write(chunk)
44
44
  end
45
45
  end
46
46
  save_as
@@ -16,12 +16,20 @@ module Joblin
16
16
  class BackgroundTaskCleaner < ActiveJob::Base
17
17
  def perform
18
18
  types = BackgroundTask.distinct.pluck(:type)
19
- types.each do |type|
20
- type = type.constantize
21
- rp = type.record_retention
19
+ types.each do |type_name|
20
+ klass = type_name ? type_name.safe_constantize : BackgroundTask
21
+ # Skip rows whose class has been renamed/removed rather than aborting
22
+ # the whole cleanup run.
23
+ next unless klass && klass <= BackgroundTask
24
+
25
+ rp = klass.record_retention
22
26
  next unless rp
23
27
 
24
- type.where("created_at < ?", rp.ago).destroy_all
28
+ # Scope by the exact type column so a parent policy doesn't reap
29
+ # subclass rows that declared their own (longer) retention.
30
+ BackgroundTask.where(type: type_name)
31
+ .where("created_at < ?", rp.ago)
32
+ .destroy_all
25
33
  end
26
34
  end
27
35
  end
@@ -22,12 +22,19 @@ module Joblin
22
22
  joins("LEFT OUTER JOIN #{t} AS t2 ON #{t}.type = t2.type AND #{t}.created_at < t2.created_at")
23
23
  .group(:id).order(:type, created_at: :desc).having('count(*) < ?', history_length)
24
24
  else
25
+ # DISTINCT ON requires the distinct expression to lead the ORDER BY;
26
+ # created_at DESC then picks the most recent row per type.
25
27
  select("DISTINCT ON (#{t}.type) #{t}.*")
28
+ .order(Arel.sql("#{t}.type, #{t}.created_at DESC"))
26
29
  end
27
30
  }
28
31
 
29
32
  def enqueue!
30
- self.workflow_state = 'scheduled' if self.workflow_state == 'unscheduled'
33
+ # Persist the transition here so it isn't lost when enter_batch reuses an
34
+ # existing batch_id (and doesn't otherwise write the record) — e.g. a
35
+ # re-enqueue after failure must not stay `failed`.
36
+ self.workflow_state = 'scheduled'
37
+ save! if changed?
31
38
 
32
39
  enter_batch do
33
40
  self.class::ExecutorJob.perform_later()
@@ -0,0 +1,10 @@
1
+ class AddIndexesToJoblinBackgroundTasks < Joblin::MiscHelper::MigrationClass
2
+ def change
3
+ # Retention cleanup filters on (type, created_at) and grouped_history orders
4
+ # by the same pair; a composite index keeps both off a sequential scan.
5
+ add_index :joblin_background_tasks, [:type, :created_at], if_not_exists: true
6
+
7
+ # Batch callbacks look tasks up by batch_id.
8
+ add_index :joblin_background_tasks, :batch_id, if_not_exists: true
9
+ end
10
+ end
@@ -221,7 +221,7 @@ module Joblin::Batching
221
221
  unless defined?(@closed)
222
222
  @closed = redis.hget(@bidkey, 'complete') == 'true'
223
223
  end
224
- raise "Cannot add jobs to Batch #{} bid - it has already entered the callback-stage" if @closed
224
+ raise "Cannot add jobs to Batch #{bid} - it has already entered the callback-stage" if @closed
225
225
  end
226
226
 
227
227
  def persist!
@@ -236,6 +236,7 @@ module Joblin::Batching
236
236
  r.hincrby("BID-#{parent_bid}", "children", 1)
237
237
  r.expire("BID-#{parent_bid}", BID_EXPIRE_TTL)
238
238
  r.zadd("BID-#{parent_bid}-bids", created_at, bid)
239
+ r.expire("BID-#{parent_bid}-bids", BID_EXPIRE_TTL)
239
240
  else
240
241
  r.zadd("BID-ROOT-bids", created_at, bid)
241
242
  end
@@ -275,38 +276,64 @@ module Joblin::Batching
275
276
 
276
277
  precount = 0
277
278
 
278
- all_results = redis do |r|
279
- return unless r.exists?("BID-#{bid}")
279
+ # WATCH the batch hash so this transaction (the caller's mutations *and*
280
+ # the state reads/expirations, some of which — e.g. hincrby "pending"
281
+ # would otherwise recreate the hash) aborts if a concurrent cleanup_redis
282
+ # deletes the batch after we've checked it exists. An aborted EXEC applies
283
+ # nothing, so re-running the block on retry is safe (no double-apply).
284
+ # Concurrent mutations of the *same* bid can also abort us; we retry a
285
+ # bounded number of times and give up loudly rather than spin forever.
286
+ max_attempts = 50
287
+ all_results =
288
+ redis do |r|
289
+ attempts = 0
290
+ loop do
291
+ attempts += 1
292
+ result = r.watch("BID-#{bid}") do |rc|
293
+ if rc.exists?("BID-#{bid}")
294
+ rc.multi do |r|
295
+ blk.call(r)
296
+ futures = r.instance_variable_get(:@futures) || r.instance_variable_get(:@pipeline)&.futures || r.instance_variable_get(:@client)&.futures
297
+ precount = futures.size
298
+
299
+ # Misc
300
+ r.hget("BID-#{bid}", "parent_bid")
301
+ r.hget("BID-#{bid}", "keep_open")
302
+ r.scard("BID-#{bid}-holds")
303
+
304
+ # Jobs
305
+ r.hincrby("BID-#{bid}", "pending", 0)
306
+ r.scard("BID-#{bid}-failed")
307
+ r.scard("BID-#{bid}-dead")
308
+
309
+ # Batches
310
+ r.hincrby("BID-#{bid}", "children", 0)
311
+ r.scard("BID-#{bid}-batches-complete")
312
+ r.scard("BID-#{bid}-batches-success")
313
+ r.scard("BID-#{bid}-batches-failed")
314
+ r.scard("BID-#{bid}-batches-stagnated")
315
+
316
+ # Touch Expirations
317
+ r.expire("BID-#{bid}", BID_EXPIRE_TTL)
318
+ r.expire("BID-#{bid}-batches-success", BID_EXPIRE_TTL)
319
+ end
320
+ else
321
+ # Batch was cleaned up; nothing to process.
322
+ rc.unwatch
323
+ :missing
324
+ end
325
+ end
280
326
 
281
- r.multi do |r|
282
- # The block probably doesn't _have_ to be part of the same transaction, but it was easier than
283
- # re-assessing possible race-conditions and is technically more performant
284
- blk.call(r)
285
- futures = r.instance_variable_get(:@futures) || r.instance_variable_get(:@pipeline)&.futures || r.instance_variable_get(:@client)&.futures
286
- precount = futures.size
287
-
288
- # Misc
289
- r.hget("BID-#{bid}", "parent_bid")
290
- r.hget("BID-#{bid}", "keep_open")
291
- r.scard("BID-#{bid}-holds")
292
-
293
- # Jobs
294
- r.hincrby("BID-#{bid}", "pending", 0)
295
- r.scard("BID-#{bid}-failed")
296
- r.scard("BID-#{bid}-dead")
297
-
298
- # Batches
299
- r.hincrby("BID-#{bid}", "children", 0)
300
- r.scard("BID-#{bid}-batches-complete")
301
- r.scard("BID-#{bid}-batches-success")
302
- r.scard("BID-#{bid}-batches-failed")
303
- r.scard("BID-#{bid}-batches-stagnated")
304
-
305
- # Touch Expirations
306
- r.expire("BID-#{bid}", BID_EXPIRE_TTL)
307
- r.expire("BID-#{bid}-batches-success", BID_EXPIRE_TTL)
327
+ # :missing => batch gone; Array => committed; nil => EXEC aborted (retry).
328
+ break result unless result.nil?
329
+
330
+ if attempts >= max_attempts
331
+ raise "with_callback_check: gave up after #{attempts} attempts due to contention on BID-#{bid}"
332
+ end
333
+ end
308
334
  end
309
- end
335
+
336
+ return if all_results == :missing
310
337
 
311
338
  # Exclude return values from the passed block
312
339
  actual_results = all_results[precount..-1]
@@ -407,7 +434,7 @@ module Joblin::Batching
407
434
 
408
435
  # User-configured parameters/arguments to pass to the callback
409
436
  callback_args = callbacks.reduce([]) do |memo, jcb|
410
- cb = JSON.load(jcb)
437
+ cb = JSON.parse(jcb)
411
438
  memo << [cb['callback'], event.to_s, cb['opts'], bid, parent_bid]
412
439
  end
413
440
 
@@ -487,7 +514,7 @@ module Joblin::Batching
487
514
  # Internal method to cleanup a Redis Hash and related keys
488
515
  def cleanup_redis_index_for(key, suffixes = [""])
489
516
  redis do |r|
490
- if r.hget(k, "created_at").present?
517
+ if r.hget(key, "created_at").present?
491
518
  r.multi do |r|
492
519
  suffixes.each do |suffix|
493
520
  r.expire(key + suffix, BID_EXPIRE_TTL)
@@ -26,7 +26,12 @@ module Joblin::Batching
26
26
  method ||= "on_#{event}"
27
27
  status = Batch::Status.new(bid)
28
28
 
29
- if clazz && object = Object.const_get(clazz)
29
+ # safe_constantize returns nil (rather than raising NameError) for an
30
+ # unknown/typo'd class, so a bad callback name logs a warning instead
31
+ # of retrying forever and stranding the triggering batch.
32
+ object = clazz.is_a?(String) ? clazz.safe_constantize : clazz
33
+
34
+ if object
30
35
  target = target == :instance ? object.new : object
31
36
  if target.respond_to?(method, true)
32
37
  target.send(method, status, opts.with_indifferent_access)
@@ -87,6 +92,7 @@ module Joblin::Batching
87
92
  r.sadd("BID-#{parent_bid}-batches-success", bid)
88
93
  r.srem("BID-#{parent_bid}-batches-failed", bid)
89
94
  r.sadd("BID-#{parent_bid}-batches-complete", bid)
95
+ r.expire("BID-#{parent_bid}-batches-complete", BID_EXPIRE_TTL)
90
96
  end
91
97
  end
92
98
 
@@ -113,6 +119,8 @@ module Joblin::Batching
113
119
  Batch.with_callback_check(parent_bid, except: [:success]) do |r|
114
120
  r.sadd("BID-#{parent_bid}-batches-complete", bid)
115
121
  r.sadd("BID-#{parent_bid}-batches-failed", bid)
122
+ r.expire("BID-#{parent_bid}-batches-complete", BID_EXPIRE_TTL)
123
+ r.expire("BID-#{parent_bid}-batches-failed", BID_EXPIRE_TTL)
116
124
  end
117
125
  end
118
126
  end
@@ -79,11 +79,12 @@ module Joblin::Batching
79
79
 
80
80
  chain = self.class.get_chain_parameter(parent_job)
81
81
 
82
- if parent_job[:job] != needed_parent_type
82
+ # Compare by name: parent_job[:job] may be a Class or its String name.
83
+ if parent_job[:job].to_s != needed_parent_type.to_s
83
84
  old_job = chain[sub_index]
84
85
  parent_job = chain[sub_index] = {
85
86
  job: needed_parent_type,
86
- parameters: [],
87
+ args: [],
87
88
  }
88
89
  sub_index = 0
89
90
  chain = self.class.get_chain_parameter(parent_job)
@@ -26,8 +26,8 @@
26
26
  <% status = Joblin::Batching::Batch::Status.new(batch) %>
27
27
  <tr>
28
28
  <td><%= safe_relative_time(batch.created_at.to_f) %></th>
29
- <td><a href="<%= root_path %>batches/<%= batch.bid %>"><%= batch.bid %></a></td>
30
- <td><%= batch.description %></th>
29
+ <td><a href="<%= root_path %>batches/<%= h(batch.bid) %>"><%= h(batch.bid) %></a></td>
30
+ <td><%= h(batch.description) %></th>
31
31
 
32
32
  <td><%= status.pending %></th>
33
33
  <td><%= status.failures %></th>
@@ -9,11 +9,11 @@
9
9
 
10
10
  <% @jobs.each do |job_desc| %>
11
11
  <tr>
12
- <td><%= job_desc[:jid] %></td>
13
- <td><%= job_desc['job'] %></td>
12
+ <td><%= h(job_desc[:jid]) %></td>
13
+ <td><%= h(job_desc['job']) %></td>
14
14
  <td>
15
15
  <code class="code-wrap">
16
- <div class="args-extended"><%= job_desc['parameters'].to_json %></div>
16
+ <div class="args-extended"><%= h(job_desc['parameters'].to_json) %></div>
17
17
  </code>
18
18
  </td>
19
19
  </tr>
@@ -8,13 +8,13 @@
8
8
  <tbody>
9
9
  <tr>
10
10
  <th colspan="2" scope=row><%= t('Batch') %></td>
11
- <td><%= @batch.bid %></td>
11
+ <td><%= h(@batch.bid) %></td>
12
12
  </tr>
13
13
  <tr>
14
14
  <th colspan="2" scope=row><%= t('Parent') %></td>
15
15
  <td>
16
16
  <% if @batch.parent_bid.present? %>
17
- <a href="<%= root_path %>batches/<%= @batch.parent_bid %>"><%= @batch.parent_bid %></a>
17
+ <a href="<%= root_path %>batches/<%= h(@batch.parent_bid) %>"><%= h(@batch.parent_bid) %></a>
18
18
  <% else %>
19
19
  ROOT
20
20
  <% end %>
@@ -26,7 +26,7 @@
26
26
  </tr>
27
27
  <tr>
28
28
  <th colspan="2" scope=row><%= t('Description') %></td>
29
- <td><%= @batch.description %></td>
29
+ <td><%= h(@batch.description) %></td>
30
30
  </tr>
31
31
  <tr>
32
32
  <th colspan="2" scope=row><%= t('Context') %></td>
@@ -63,7 +63,7 @@
63
63
  %>
64
64
  <% if @jobs.any? && @total_size > @count.to_i %>
65
65
  <div class="col-sm-4">
66
- <%= erb get_template(:_pagination), locals: { url: "#{root_path}batches/#{@batch.bid}", page_key: :job_page } %>
66
+ <%= erb get_template(:_pagination), locals: { url: "#{root_path}batches/#{h(@batch.bid)}", page_key: :job_page } %>
67
67
  </div>
68
68
  <% end %>
69
69
  </header>
@@ -74,7 +74,7 @@
74
74
  </div>
75
75
  <% end %>
76
76
 
77
- <form class="form-horizontal" action="<%= root_path %>batches/<%= @batch.bid %>" method="post">
77
+ <form class="form-horizontal" action="<%= root_path %>batches/<%= h(@batch.bid) %>" method="post">
78
78
  <%= csrf_tag %>
79
79
  <a class="btn btn-default" href="<%= root_path %>batches"><%= t('GoBack') %></a>
80
80
  <input class="btn btn-danger" type="submit" name="delete" value="<%= t('Delete') %>" data-confirm="<%= t('AreYouSure') %>" />
@@ -13,11 +13,11 @@
13
13
  </tr>
14
14
  <tr>
15
15
  <th scope=row><%= t('Description') %></td>
16
- <td><%= @pool.description %></td>
16
+ <td><%= h(@pool.description) %></td>
17
17
  </tr>
18
18
  <tr>
19
19
  <th scope=row><%= t('Type') %></td>
20
- <td><%= @pool.order.to_s.upcase %></td>
20
+ <td><%= h(@pool.order.to_s.upcase) %></td>
21
21
  </tr>
22
22
  <tr>
23
23
  <th scope=row><%= t('Concurrency') %></td>
@@ -61,13 +61,13 @@
61
61
 
62
62
  <% @active_tasks.each do |job_desc| %>
63
63
  <tr>
64
- <td><a href="<%= root_path %>batches/<%= job_desc['pool_wrapper_batch'] %>"><%= job_desc['pool_wrapper_batch'] %></a></td>
65
- <td><%= job_desc['job'] %></td>
64
+ <td><a href="<%= root_path %>batches/<%= h(job_desc['pool_wrapper_batch']) %>"><%= h(job_desc['pool_wrapper_batch']) %></a></td>
65
+ <td><%= h(job_desc['job']) %></td>
66
66
  <td>
67
67
  <code class="code-wrap">
68
68
  <div class="args-extended">
69
- <%= job_desc['args']&.to_json %>
70
- <%= job_desc['kwargs']&.to_json %>
69
+ <%= h(job_desc['args']&.to_json) %>
70
+ <%= h(job_desc['kwargs']&.to_json) %>
71
71
  </div>
72
72
  </code>
73
73
  </td>
@@ -90,7 +90,7 @@
90
90
  %>
91
91
  <% if @jobs.any? && @total_size > @count.to_i %>
92
92
  <div class="col-sm-4">
93
- <%= erb get_template(:_pagination), locals: { url: "#{root_path}pools/#{@pool.pid}", page_key: :job_page } %>
93
+ <%= erb get_template(:_pagination), locals: { url: "#{root_path}pools/#{h(@pool.pid)}", page_key: :job_page } %>
94
94
  </div>
95
95
  <% end %>
96
96
  </header>
@@ -111,18 +111,18 @@
111
111
 
112
112
  <% @jobs.each do |job_desc| %>
113
113
  <tr>
114
- <td><%= job_desc['job'] %></td>
114
+ <td><%= h(job_desc['job']) %></td>
115
115
  <td>
116
116
  <code class="code-wrap">
117
117
  <div class="args-extended">
118
- <%= job_desc['args']&.to_json %>
119
- <%= job_desc['kwargs']&.to_json %>
118
+ <%= h(job_desc['args']&.to_json) %>
119
+ <%= h(job_desc['kwargs']&.to_json) %>
120
120
  </div>
121
121
  </code>
122
122
  </td>
123
- <td><a href="<%= root_path %>batches/<%= job_desc['pool_wrapper_batch'] %>"><%= job_desc['pool_wrapper_batch'] %></a></td>
123
+ <td><a href="<%= root_path %>batches/<%= h(job_desc['pool_wrapper_batch']) %>"><%= h(job_desc['pool_wrapper_batch']) %></a></td>
124
124
  <% if @pool.order == 'priority' %>
125
- <td><%= job_desc['priority'] %></td>
125
+ <td><%= h(job_desc['priority']) %></td>
126
126
  <% end %>
127
127
  </tr>
128
128
  <% end %>
@@ -130,7 +130,7 @@
130
130
  </div>
131
131
  <% end %>
132
132
 
133
- <form class="form-horizontal" action="<%= root_path %>pools/<%= @pool.pid %>" method="post">
133
+ <form class="form-horizontal" action="<%= root_path %>pools/<%= h(@pool.pid) %>" method="post">
134
134
  <%= csrf_tag %>
135
135
  <a class="btn btn-default" href="<%= root_path %>pools"><%= t('GoBack') %></a>
136
136
  <input class="btn btn-danger" type="submit" name="delete" value="<%= t('Delete') %>" data-confirm="<%= t('AreYouSure') %>" />
@@ -29,9 +29,9 @@
29
29
  <% @pools.each do |pool| %>
30
30
  <tr>
31
31
  <td><%= safe_relative_time(pool.created_at.to_f) %></th>
32
- <td><a href="<%= root_path %>pools/<%= pool.pid %>"><%= pool.pid %></a></td>
33
- <td><%= pool.description %></th>
34
- <td><%= pool.order.to_s.upcase %></th>
32
+ <td><a href="<%= root_path %>pools/<%= h(pool.pid) %>"><%= h(pool.pid) %></a></td>
33
+ <td><%= h(pool.description) %></th>
34
+ <td><%= h(pool.order.to_s.upcase) %></th>
35
35
  <td><%= pool.concurrency %></th>
36
36
  <td><%= pool.active_count %></th>
37
37
  <td><%= pool.pending_count %></th>
@@ -39,6 +39,18 @@ module Joblin::Batching::Compat::Sidekiq
39
39
  (env["rack.route_params"] || {})[key.to_sym]
40
40
  end
41
41
 
42
+ # Read a query-string parameter such as `count` or `page`.
43
+ #
44
+ # Sidekiq 7+ defines `url_params(key)` on the web action and returns the
45
+ # query value only. Sidekiq 6.x has no `url_params`; there the action's
46
+ # `params` is an indifferent hash that already merges the query string,
47
+ # so we read from it. On Sidekiq 7/8 the action's own `url_params`
48
+ # shadows this fallback (a class's instance method wins over one from an
49
+ # included module), so this definition only takes effect on Sidekiq 6.x.
50
+ def url_params(key)
51
+ params[key]
52
+ end
53
+
42
54
  def tree_data(root_bid, slice: nil)
43
55
  tree_bids = Joblin::Batching::Batch.bid_hierarchy(root_bid, slice: slice)
44
56
 
@@ -98,7 +110,11 @@ module Joblin::Batching::Compat::Sidekiq
98
110
  own_keys = batch.context.own.keys
99
111
  batch.context.flatten.each do |k,v|
100
112
  added = own_keys.include? k
101
- bits << " <span class=\"key #{added ? 'own' : 'inherited'}\">\"#{k}\": #{v.to_json},</span>"
113
+ # Both the key and the JSON-encoded value are app/user data; escape
114
+ # them so batch context can't inject markup into this page.
115
+ safe_key = Rack::Utils.escape_html(k.to_s)
116
+ safe_val = Rack::Utils.escape_html(v.to_json)
117
+ bits << " <span class=\"key #{added ? 'own' : 'inherited'}\">\"#{safe_key}\": #{safe_val},</span>"
102
118
  end
103
119
  bits = [
104
120
  "{ // <span class=\"own\">Added</span> / <span class=\"inherited\">Inherited</span>",
@@ -144,7 +144,7 @@ module Joblin::Batching
144
144
  context_hash.each do |k, v|
145
145
  v.freeze
146
146
  end
147
- context_hash.freeze unless editable
147
+ context_hash.freeze unless editable == true || editable == "true"
148
148
 
149
149
  @hash_map[bid] = context_hash
150
150
  else
@@ -2,6 +2,12 @@ require_relative './base_job'
2
2
 
3
3
  module Joblin::Batching
4
4
  class ManagedBatchJob < BaseJob
5
+ # preflight_check: an optional "Class.method" (or bare "method" on Object)
6
+ # string, invoked with the deserialized next job just before it is enqueued.
7
+ # Its return value controls the sequence:
8
+ # :abort -> abort the whole managed batch (cleanup and stop)
9
+ # falsy -> skip this job and advance to the next one
10
+ # truthy -> enqueue and run this job (the default lambda returns true)
5
11
  def self.make_batch(sub_jobs, ordered: true, concurrency: nil, context: nil, preflight_check: nil, description: nil, desc_prefix: nil, &blk)
6
12
  desc_prefix ||= ''
7
13
 
@@ -127,13 +133,13 @@ module Joblin::Batching
127
133
  break unless next_job_json.present?
128
134
 
129
135
  next_job = JSON.parse(next_job_json)
130
- next_job = ::ActiveJob::Arguments.deserialize(next_job)[0]
136
+ next_job = ::ActiveJob::Arguments.deserialize(next_job)[0]&.symbolize_keys
131
137
 
132
138
  preflight_result = preflight_check.call(next_job)
133
139
  if preflight_result == :abort
134
140
  cleanup_redis(nil, { "managed_batch_id" => man_batch_id })
135
141
  break
136
- elsif !preflight_check
142
+ elsif !preflight_result
137
143
  next
138
144
  end
139
145
 
@@ -37,6 +37,13 @@ module Joblin::Batching
37
37
  end
38
38
 
39
39
  def add_jobs(job_descs, skip_refill: false)
40
+ # Adding to a pool that has already been cleaned up (e.g. it drained to
41
+ # empty and auto-removed) would strand the job forever. Long-lived
42
+ # producers that add over time must hold the pool open with `keep_open!`.
43
+ unless redis.exists?(redis_key)
44
+ raise "Cannot add jobs to Pool #{pid}: it no longer exists. Wrap long-lived producers in `keep_open!`."
45
+ end
46
+
40
47
  job_descs.each do |job_desc|
41
48
  wrapper = Batch.new
42
49
  wrapper.description = "Pool Job Wrapper (PID: #{pid})"
@@ -147,8 +154,10 @@ module Joblin::Batching
147
154
  # Administrative/console method to cleanup expired pools from the WebUI
148
155
  def self.cleanup_redis_index!
149
156
  suffixes = ["", "-active", "-jobs"]
150
- r.zrangebyscore("pools", "0", Batch::BID_EXPIRE_TTL.seconds.ago.to_i).each do |pid|
151
- r.zrem("pools", pid) if Batch.cleanup_redis_index_for("POOLID-#{pid}", suffixes)
157
+ redis do |r|
158
+ r.zrangebyscore("pools", "0", Batch::BID_EXPIRE_TTL.seconds.ago.to_i).each do |pid|
159
+ r.zrem("pools", pid) if Batch.cleanup_redis_index_for("POOLID-#{pid}", suffixes)
160
+ end
152
161
  end
153
162
  end
154
163
 
@@ -245,7 +254,7 @@ module Joblin::Batching
245
254
  self.created_at = Time.now.utc.to_f
246
255
  self.description = description
247
256
  self.order = order
248
- self.concurrency = concurrency
257
+ self.concurrency = concurrency || 1
249
258
  self.clean_when_empty = clean_when_empty
250
259
  self.on_failed_job = on_failed_job
251
260
  flush_pending_attrs
@@ -15,7 +15,8 @@ if checkin_item ~= "" then
15
15
  end
16
16
 
17
17
  local pool_type = redis.call('HGET', poolkey, "order")
18
- local allotment = tonumber(redis.call("HGET", poolkey, "concurrency"))
18
+ -- Treat an unset/blank concurrency as 1 rather than erroring on tonumber("") -> nil
19
+ local allotment = tonumber(redis.call("HGET", poolkey, "concurrency")) or 1
19
20
  local active = redis.call("HLEN", activekey) + (redis.call("HGET", poolkey, "_active_count") or 0)
20
21
 
21
22
  local pop_count = allotment - active
@@ -1,4 +1,10 @@
1
1
 
2
+ -- If the batch hash is already gone (cleaned up), don't resurrect it with a
3
+ -- fresh HSET. Report it as already-processed so no callback is (re-)scheduled.
4
+ if redis.call('EXISTS', KEYS[1]) == 0 then
5
+ return 'true'
6
+ end
7
+
2
8
  local previously_scheduled = redis.call('HGET', KEYS[1], ARGV[1])
3
9
  redis.call('HSET', KEYS[1], ARGV[1], 'true')
4
10