joblin 0.1.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 (111) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +1 -0
  3. data/app/models/joblin/background_task/api_access.rb +148 -0
  4. data/app/models/joblin/background_task/attachments.rb +47 -0
  5. data/app/models/joblin/background_task/executor.rb +63 -0
  6. data/app/models/joblin/background_task/options.rb +75 -0
  7. data/app/models/joblin/background_task/retention_policy.rb +28 -0
  8. data/app/models/joblin/background_task.rb +72 -0
  9. data/app/models/joblin/concerns/job_working_dirs.rb +21 -0
  10. data/db/migrate/20250903184852_create_background_tasks.rb +12 -0
  11. data/joblin.gemspec +35 -0
  12. data/lib/joblin/batching/batch.rb +537 -0
  13. data/lib/joblin/batching/callback.rb +135 -0
  14. data/lib/joblin/batching/chain_builder.rb +247 -0
  15. data/lib/joblin/batching/compat/active_job.rb +108 -0
  16. data/lib/joblin/batching/compat/sidekiq/web/batches_assets/css/styles.less +182 -0
  17. data/lib/joblin/batching/compat/sidekiq/web/batches_assets/js/batch_tree.js +108 -0
  18. data/lib/joblin/batching/compat/sidekiq/web/batches_assets/js/util.js +2 -0
  19. data/lib/joblin/batching/compat/sidekiq/web/helpers.rb +41 -0
  20. data/lib/joblin/batching/compat/sidekiq/web/views/_batch_tree.erb +6 -0
  21. data/lib/joblin/batching/compat/sidekiq/web/views/_batches_table.erb +44 -0
  22. data/lib/joblin/batching/compat/sidekiq/web/views/_common.erb +13 -0
  23. data/lib/joblin/batching/compat/sidekiq/web/views/_jobs_table.erb +21 -0
  24. data/lib/joblin/batching/compat/sidekiq/web/views/_pagination.erb +26 -0
  25. data/lib/joblin/batching/compat/sidekiq/web/views/batch.erb +81 -0
  26. data/lib/joblin/batching/compat/sidekiq/web/views/batches.erb +23 -0
  27. data/lib/joblin/batching/compat/sidekiq/web/views/pool.erb +137 -0
  28. data/lib/joblin/batching/compat/sidekiq/web/views/pools.erb +47 -0
  29. data/lib/joblin/batching/compat/sidekiq/web.rb +218 -0
  30. data/lib/joblin/batching/compat/sidekiq.rb +149 -0
  31. data/lib/joblin/batching/compat.rb +20 -0
  32. data/lib/joblin/batching/context_hash.rb +157 -0
  33. data/lib/joblin/batching/hier_batch_ids.lua +25 -0
  34. data/lib/joblin/batching/jobs/base_job.rb +7 -0
  35. data/lib/joblin/batching/jobs/concurrent_batch_job.rb +20 -0
  36. data/lib/joblin/batching/jobs/managed_batch_job.rb +175 -0
  37. data/lib/joblin/batching/jobs/serial_batch_job.rb +20 -0
  38. data/lib/joblin/batching/pool.rb +254 -0
  39. data/lib/joblin/batching/pool_refill.lua +47 -0
  40. data/lib/joblin/batching/schedule_callback.lua +14 -0
  41. data/lib/joblin/batching/status.rb +89 -0
  42. data/lib/joblin/engine.rb +15 -0
  43. data/lib/joblin/lazy_access.rb +72 -0
  44. data/lib/joblin/uniqueness/compat/active_job.rb +75 -0
  45. data/lib/joblin/uniqueness/compat/sidekiq.rb +135 -0
  46. data/lib/joblin/uniqueness/compat.rb +20 -0
  47. data/lib/joblin/uniqueness/configuration.rb +25 -0
  48. data/lib/joblin/uniqueness/job_uniqueness.rb +49 -0
  49. data/lib/joblin/uniqueness/lock_context.rb +199 -0
  50. data/lib/joblin/uniqueness/locksmith.rb +92 -0
  51. data/lib/joblin/uniqueness/on_conflict/base.rb +32 -0
  52. data/lib/joblin/uniqueness/on_conflict/log.rb +13 -0
  53. data/lib/joblin/uniqueness/on_conflict/null_strategy.rb +9 -0
  54. data/lib/joblin/uniqueness/on_conflict/raise.rb +11 -0
  55. data/lib/joblin/uniqueness/on_conflict/reject.rb +21 -0
  56. data/lib/joblin/uniqueness/on_conflict/reschedule.rb +20 -0
  57. data/lib/joblin/uniqueness/on_conflict.rb +62 -0
  58. data/lib/joblin/uniqueness/strategy/base.rb +107 -0
  59. data/lib/joblin/uniqueness/strategy/until_and_while_executing.rb +35 -0
  60. data/lib/joblin/uniqueness/strategy/until_executed.rb +20 -0
  61. data/lib/joblin/uniqueness/strategy/until_executing.rb +20 -0
  62. data/lib/joblin/uniqueness/strategy/until_expired.rb +16 -0
  63. data/lib/joblin/uniqueness/strategy/while_executing.rb +26 -0
  64. data/lib/joblin/uniqueness/strategy.rb +27 -0
  65. data/lib/joblin/uniqueness/unique_job_common.rb +79 -0
  66. data/lib/joblin/version.rb +3 -0
  67. data/lib/joblin.rb +37 -0
  68. data/spec/batching/batch_spec.rb +493 -0
  69. data/spec/batching/callback_spec.rb +38 -0
  70. data/spec/batching/compat/active_job_spec.rb +107 -0
  71. data/spec/batching/compat/sidekiq_spec.rb +127 -0
  72. data/spec/batching/context_hash_spec.rb +54 -0
  73. data/spec/batching/flow_spec.rb +82 -0
  74. data/spec/batching/integration/fail_then_succeed.rb +42 -0
  75. data/spec/batching/integration/integration.rb +57 -0
  76. data/spec/batching/integration/nested.rb +88 -0
  77. data/spec/batching/integration/simple.rb +47 -0
  78. data/spec/batching/integration/workflow.rb +134 -0
  79. data/spec/batching/integration_helper.rb +50 -0
  80. data/spec/batching/pool_spec.rb +161 -0
  81. data/spec/batching/status_spec.rb +76 -0
  82. data/spec/batching/support/base_job.rb +19 -0
  83. data/spec/batching/support/sample_callback.rb +2 -0
  84. data/spec/internal/config/database.yml +5 -0
  85. data/spec/internal/config/routes.rb +5 -0
  86. data/spec/internal/config/storage.yml +3 -0
  87. data/spec/internal/db/combustion_test.sqlite +0 -0
  88. data/spec/internal/db/schema.rb +6 -0
  89. data/spec/internal/log/test.log +48200 -0
  90. data/spec/internal/public/favicon.ico +0 -0
  91. data/spec/models/background_task_spec.rb +41 -0
  92. data/spec/spec_helper.rb +29 -0
  93. data/spec/uniqueness/compat/active_job_spec.rb +49 -0
  94. data/spec/uniqueness/compat/sidekiq_spec.rb +68 -0
  95. data/spec/uniqueness/lock_context_spec.rb +106 -0
  96. data/spec/uniqueness/on_conflict/log_spec.rb +11 -0
  97. data/spec/uniqueness/on_conflict/raise_spec.rb +10 -0
  98. data/spec/uniqueness/on_conflict/reschedule_spec.rb +63 -0
  99. data/spec/uniqueness/on_conflict_spec.rb +16 -0
  100. data/spec/uniqueness/spec_helper.rb +19 -0
  101. data/spec/uniqueness/strategy/base_spec.rb +100 -0
  102. data/spec/uniqueness/strategy/until_and_while_executing_spec.rb +48 -0
  103. data/spec/uniqueness/strategy/until_executed_spec.rb +23 -0
  104. data/spec/uniqueness/strategy/until_executing_spec.rb +23 -0
  105. data/spec/uniqueness/strategy/until_expired_spec.rb +23 -0
  106. data/spec/uniqueness/strategy/while_executing_spec.rb +33 -0
  107. data/spec/uniqueness/support/lock_strategy.rb +28 -0
  108. data/spec/uniqueness/support/on_conflict.rb +24 -0
  109. data/spec/uniqueness/support/test_worker.rb +19 -0
  110. data/spec/uniqueness/unique_job_common_spec.rb +45 -0
  111. metadata +308 -0
@@ -0,0 +1,537 @@
1
+
2
+ require_relative "./callback"
3
+ require_relative "./context_hash"
4
+ require_relative "./status"
5
+ require_relative "./pool"
6
+ Dir[File.dirname(__FILE__) + "/jobs/*.rb"].each { |file| require file }
7
+ require_relative "./chain_builder"
8
+
9
+ # Implement Job Batching similar to Sidekiq::Batch. Supports ActiveJob and Sidekiq, or a mix thereof.
10
+ # Much of this code is modifed/extended from https://github.com/breamware/sidekiq-batch
11
+
12
+ module Joblin::Batching
13
+ CURRENT_BATCH_THREAD_KEY = :job_batches_batch
14
+
15
+ class Batch
16
+ include RediConn::RedisModel
17
+
18
+ class NoBlockGivenError < StandardError; end
19
+
20
+ delegate :redis, to: :class
21
+
22
+ BID_EXPIRE_TTL = 90.days.to_i
23
+ INDEX_ALL_BATCHES = false
24
+ SCHEDULE_CALLBACK = RediConn::RedisScript.new(Pathname.new(__FILE__) + "../schedule_callback.lua")
25
+ BID_HIERARCHY = RediConn::RedisScript.new(Pathname.new(__FILE__) + "../hier_batch_ids.lua")
26
+
27
+ attr_reader :bid
28
+
29
+ def self.current
30
+ Thread.current[CURRENT_BATCH_THREAD_KEY]
31
+ end
32
+
33
+ def self.current_context
34
+ self.current&.context
35
+ end
36
+
37
+ def initialize(existing_bid = nil)
38
+ @bid = existing_bid || SecureRandom.urlsafe_base64(10)
39
+ @existing = !(!existing_bid || existing_bid.empty?) # Basically existing_bid.present?
40
+ @initialized = false
41
+ @bidkey = "BID-" + @bid.to_s
42
+ self.created_at = Time.now.utc.to_f unless @existing
43
+ end
44
+
45
+ redis_attr :description
46
+ redis_attr :created_at
47
+ redis_attr :callback_queue, read_only: false
48
+ redis_attr :callback_params, :json
49
+ redis_attr :allow_context_changes, :bool
50
+
51
+ def context
52
+ return @context if defined?(@context)
53
+
54
+ if (@initialized || @existing)
55
+ @context = ContextHash.new(bid)
56
+ else
57
+ @context = ContextHash.new(bid, {})
58
+ end
59
+ end
60
+
61
+ def context=(value)
62
+ raise "context is read-only once the batch has been started" if (@initialized || @existing) # && !allow_context_changes
63
+ raise "context must be a Hash" unless value.is_a?(Hash) || value.nil?
64
+ return nil if value.nil? && @context.nil?
65
+
66
+ value = {} if value.nil?
67
+ value = value.local if value.is_a?(ContextHash)
68
+
69
+ @context ||= ContextHash.new(bid, {})
70
+ @context.set_local(value)
71
+ # persist_bid_attr('context', JSON.unparse(@context.local))
72
+ end
73
+
74
+ def save_context_changes
75
+ @context&.save!
76
+ end
77
+
78
+ # Events:
79
+ # :complete - triggered once all jobs have been executed, regardless of success or failure.
80
+ # :success - triggered once all jobs haves successfully executed.
81
+ # :death - triggered after any job enters the dead state.
82
+ # :stagnated - triggered when a job dies and no other jobs/batches are active.
83
+ def on(event, callback, options = {})
84
+ return unless Callback::VALID_CALLBACKS.include?(event.to_sym)
85
+
86
+ callback_key = "#{@bidkey}-callbacks-#{event}"
87
+ redis.multi do |r|
88
+ r.sadd(callback_key, JSON.unparse({
89
+ callback: callback,
90
+ opts: options
91
+ }))
92
+ r.expire(callback_key, BID_EXPIRE_TTL)
93
+ end
94
+ end
95
+
96
+ def jobs
97
+ raise NoBlockGivenError unless block_given?
98
+
99
+ persist!
100
+
101
+ # TODO This keep_open! block is probably desired, but it has some caveats:
102
+ # - Old logic didn't auto-clean empty batches
103
+ # - Could be an issue if the Thread crashes at a bad moment (do we even need to plan for this?)
104
+ # - Technically there could be a race condition without it, but such hasn't been observed
105
+
106
+ # keep_open! do
107
+ begin
108
+ parent = Thread.current[CURRENT_BATCH_THREAD_KEY]
109
+ Thread.current[CURRENT_BATCH_THREAD_KEY] = self
110
+ yield
111
+ ensure
112
+ Thread.current[CURRENT_BATCH_THREAD_KEY] = parent
113
+ end
114
+ # end
115
+
116
+ nil
117
+ end
118
+
119
+ # Mark this Batch as a placeholder. It will be persisted to Redis, but no jobs will be added.
120
+ # From here, you can either use `.jobs` on the batch to add jobs as usual, or call `.let_close!` to allow cleanup to occur.
121
+ def placeholder!
122
+ # TODO Provide a stable `let_close!` token?
123
+ persist!
124
+ end
125
+
126
+ def invalidate_all
127
+ redis.setex("invalidated-bid-#{bid}", BID_EXPIRE_TTL, 1)
128
+ end
129
+
130
+ def parent_bid
131
+ redis.hget(@bidkey, "parent_bid")
132
+ end
133
+
134
+ def parent
135
+ if parent_bid
136
+ Batch.new(parent_bid)
137
+ end
138
+ end
139
+
140
+ def valid?(batch = self)
141
+ valid = !redis.exists?("invalidated-bid-#{batch.bid}")
142
+ batch.parent ? valid && valid?(batch.parent) : valid
143
+ end
144
+
145
+ def keep_open!(token = SecureRandom.urlsafe_base64(10))
146
+ if block_given?
147
+ begin
148
+ token = keep_open!(token)
149
+ yield
150
+ ensure
151
+ let_close!(token)
152
+ end
153
+ else
154
+ persist!
155
+
156
+ redis.multi do |r|
157
+ r.sadd("#{@bidkey}-holds", token)
158
+ r.expire("#{@bidkey}-holds", BID_EXPIRE_TTL)
159
+ end
160
+
161
+ assert_batch_is_open
162
+
163
+ token
164
+ end
165
+ end
166
+
167
+ def let_close!(token = :unset)
168
+ self.class.with_callback_check(bid, only: %i[complete success]) do |r|
169
+ if token == :unset # Legacy
170
+ r.del("#{@bidkey}-holds")
171
+ r.hset(@bidkey, 'keep_open', "false")
172
+ else
173
+ r.srem("#{@bidkey}-holds", token)
174
+ end
175
+ end
176
+ end
177
+
178
+ def self.with_batch(batch)
179
+ batch = self.new(batch) if batch.is_a?(String)
180
+ parent = Thread.current[CURRENT_BATCH_THREAD_KEY]
181
+ Thread.current[CURRENT_BATCH_THREAD_KEY] = batch
182
+ yield
183
+ ensure
184
+ Thread.current[CURRENT_BATCH_THREAD_KEY] = parent
185
+ end
186
+
187
+ # Any Batches or Jobs created in the given block won't be assocaiated to the current batch
188
+ def self.without_batch(&blk)
189
+ with_batch(nil, &blk)
190
+ end
191
+
192
+ def append_jobs(jids)
193
+ jids = Array(jids)
194
+ jids = jids.uniq
195
+ return unless jids.size > 0
196
+
197
+ redis do |r|
198
+ tme = Time.now.utc.to_f
199
+ added = r.zadd(@bidkey + "-jids", jids.map{|jid| [tme, jid] }, nx: true)
200
+ r.multi do |r|
201
+ r.hincrby(@bidkey, "pending", added)
202
+ r.hincrby(@bidkey, "job_count", added)
203
+ r.expire(@bidkey, BID_EXPIRE_TTL)
204
+ r.expire(@bidkey + "-jids", BID_EXPIRE_TTL)
205
+ end
206
+ end
207
+ end
208
+
209
+ protected
210
+
211
+ def redis_key
212
+ @bidkey
213
+ end
214
+
215
+ def flush_pending_attrs
216
+ super
217
+ redis.zadd("batches", created_at, bid) if INDEX_ALL_BATCHES
218
+ end
219
+
220
+ def assert_batch_is_open
221
+ unless defined?(@closed)
222
+ @closed = redis.hget(@bidkey, 'complete') == 'true'
223
+ end
224
+ raise "Cannot add jobs to Batch #{} bid - it has already entered the callback-stage" if @closed
225
+ end
226
+
227
+ def persist!
228
+ if !@existing && !@initialized
229
+ parent_bid = Thread.current[CURRENT_BATCH_THREAD_KEY]&.bid
230
+
231
+ redis.multi do |r|
232
+ r.hset(@bidkey, "parent_bid", parent_bid.to_s) if parent_bid
233
+ r.expire(@bidkey, BID_EXPIRE_TTL)
234
+
235
+ if parent_bid
236
+ r.hincrby("BID-#{parent_bid}", "children", 1)
237
+ r.expire("BID-#{parent_bid}", BID_EXPIRE_TTL)
238
+ r.zadd("BID-#{parent_bid}-bids", created_at, bid)
239
+ else
240
+ r.zadd("BID-ROOT-bids", created_at, bid)
241
+ end
242
+ end
243
+
244
+ flush_pending_attrs
245
+ @context&.save!
246
+
247
+ @initialized = true
248
+ end
249
+ end
250
+
251
+ class << self
252
+ def current
253
+ Thread.current[CURRENT_BATCH_THREAD_KEY]
254
+ end
255
+
256
+ def current_context
257
+ current&.context || {}
258
+ end
259
+
260
+ # Perform a success/failure/complete/etc transaction against Redis, checking
261
+ # if any callbacks should be triggered
262
+ def with_callback_check(bid, except: [], only: Callback::VALID_CALLBACKS, &blk)
263
+ except = Array(except).map(&:to_sym)
264
+ only = Array(only).map(&:to_sym)
265
+
266
+ precount = 0
267
+
268
+ all_results = redis do |r|
269
+ return unless r.exists?("BID-#{bid}")
270
+
271
+ r.multi do |r|
272
+ # The block probably doesn't _have_ to be part of the same transaction, but it was easier than
273
+ # re-assessing possible race-conditions and is technically more performant
274
+ blk.call(r)
275
+ futures = r.instance_variable_get(:@futures) || r.instance_variable_get(:@pipeline)&.futures || r.instance_variable_get(:@client)&.futures
276
+ precount = futures.size
277
+
278
+ # Misc
279
+ r.hget("BID-#{bid}", "parent_bid")
280
+ r.hget("BID-#{bid}", "keep_open")
281
+ r.scard("BID-#{bid}-holds")
282
+
283
+ # Jobs
284
+ r.hincrby("BID-#{bid}", "pending", 0)
285
+ r.scard("BID-#{bid}-failed")
286
+ r.scard("BID-#{bid}-dead")
287
+
288
+ # Batches
289
+ r.hincrby("BID-#{bid}", "children", 0)
290
+ r.scard("BID-#{bid}-batches-complete")
291
+ r.scard("BID-#{bid}-batches-success")
292
+ r.scard("BID-#{bid}-batches-failed")
293
+ r.scard("BID-#{bid}-batches-stagnated")
294
+
295
+ # Touch Expirations
296
+ r.expire("BID-#{bid}", BID_EXPIRE_TTL)
297
+ r.expire("BID-#{bid}-batches-success", BID_EXPIRE_TTL)
298
+ end
299
+ end
300
+
301
+ # Exclude return values from the passed block
302
+ actual_results = all_results[precount..-1]
303
+
304
+ # "pending" = not successful (yet)
305
+ # "failed" = dead or retrying
306
+ # "complete" = successful or failed
307
+
308
+ parent_bid, keep_open, holds, \
309
+ pending_jobs, failed_jobs, dead_jobs, \
310
+ child_batches, complete_batches, success_batches, failed_batches, stagnated_batches \
311
+ = actual_results
312
+
313
+ pending_batches = child_batches - success_batches
314
+
315
+ if keep_open == 'true' || (holds && holds > 0)
316
+ except << :complete
317
+ except << :success
318
+ end
319
+
320
+ trigger_callback = ->(callback) {
321
+ next if except.include?(callback.to_sym)
322
+ next unless only.include?(callback.to_sym)
323
+
324
+ Batch.logger.debug {"Finalize #{callback} bid: #{parent_bid}"}
325
+ enqueue_callbacks(callback, bid)
326
+ }
327
+
328
+ # Handling all of these cases in one method may be a little less performant than in more specialized methods, but
329
+ # I was dealing with duplicate Redis queries and checks being made in up to 4 places and it was getting out of
330
+ # hand - this combined method should be easier to maintain and reason about
331
+
332
+ all_successful = pending_jobs.zero? && child_batches == success_batches
333
+
334
+ if all_successful || (pending_jobs == failed_jobs && child_batches == complete_batches) # All Complete
335
+ trigger_callback.call(:complete)
336
+ end
337
+
338
+ if all_successful # All Successful
339
+ trigger_callback.call(:success)
340
+ elsif pending_jobs == dead_jobs && pending_batches == stagnated_batches # Stagnated
341
+ trigger_callback.call(:stagnated)
342
+ end
343
+
344
+ all_results[0...precount]
345
+ end
346
+
347
+ def process_failed_job(bid, jid)
348
+ with_callback_check(bid, except: [:success]) do |r|
349
+ r.sadd("BID-#{bid}-failed", jid)
350
+ r.expire("BID-#{bid}-failed", BID_EXPIRE_TTL)
351
+ end
352
+ end
353
+
354
+ # Dead jobs are a Sidekiq feature.
355
+ # If this is called for a job, process_failed_job was also called
356
+ def process_dead_job(bid, jid)
357
+ enqueue_callbacks(:death, bid)
358
+
359
+ with_callback_check(bid) do |r|
360
+ r.sadd("BID-#{bid}-dead", jid)
361
+ r.expire("BID-#{bid}-dead", BID_EXPIRE_TTL)
362
+ end
363
+ end
364
+
365
+ def process_successful_job(bid, jid)
366
+ with_callback_check(bid) do |r|
367
+ r.srem("BID-#{bid}-failed", jid)
368
+ r.hincrby("BID-#{bid}", "pending", -1)
369
+ r.hincrby("BID-#{bid}", "successful-jobs", 1)
370
+ r.zrem("BID-#{bid}-jids", jid)
371
+ end
372
+ end
373
+
374
+ def enqueue_callbacks(event, bid)
375
+ batch_key = "BID-#{bid}"
376
+ callback_key = "#{batch_key}-callbacks-#{event}"
377
+
378
+ exists, callbacks, queue, parent_bid, callback_params = redis do |r|
379
+ r.multi do |r|
380
+ r.exists?(batch_key)
381
+
382
+ r.smembers(callback_key)
383
+ r.hget(batch_key, "callback_queue")
384
+ r.hget(batch_key, "parent_bid")
385
+ r.hget(batch_key, "callback_params")
386
+ end
387
+ end
388
+
389
+ return unless exists
390
+
391
+ queue ||= "default"
392
+ parent_bid = !parent_bid || parent_bid.empty? ? nil : parent_bid # Basically parent_bid.blank?
393
+
394
+ # Internal callback params. If this is present, we're trying to enqueue callbacks for a callback, which is a special case that
395
+ # indicates that the callback completed and we need to close the triggering batch (which is in a done-but-not-cleaned state)
396
+ callback_params = JSON.parse(callback_params) if callback_params.present?
397
+
398
+ # User-configured parameters/arguments to pass to the callback
399
+ callback_args = callbacks.reduce([]) do |memo, jcb|
400
+ cb = JSON.load(jcb)
401
+ memo << [cb['callback'], event.to_s, cb['opts'], bid, parent_bid]
402
+ end
403
+
404
+ opts = {"bid" => bid, "event" => event}
405
+ should_schedule_batch = callback_args.present? && !callback_params.present?
406
+ already_processed = redis do |r|
407
+ SCHEDULE_CALLBACK.call(r, [batch_key], [event.to_s, should_schedule_batch.to_s, BID_EXPIRE_TTL])
408
+ end
409
+
410
+ return if already_processed == 'true'
411
+
412
+ if should_schedule_batch
413
+ logger.debug {"Enqueue callback bid: #{bid} event: #{event} args: #{callback_args.inspect}"}
414
+
415
+ # Create a new Batch to handle the callbacks and add it to the _parent_ batch
416
+ # (this ensures that the parent's lifecycle status can't change until the child's callbacks are done)
417
+ with_batch(parent_bid) do
418
+ cb_batch = self.new
419
+ cb_batch.callback_params = {
420
+ for_bid: bid,
421
+ event: event,
422
+ }
423
+ opts['callback_bid'] = cb_batch.bid
424
+
425
+ logger.debug {"Adding callback batch: #{cb_batch.bid} for batch: #{bid}"}
426
+ cb_batch.jobs do
427
+ push_callbacks(callback_args, queue)
428
+ end
429
+ end
430
+ end
431
+
432
+ if callback_params.present?
433
+ # This is a callback for a callback. Passing `origin` to the Finalizer allows it to also cleanup the original/callback-triggering batch
434
+ opts['origin'] = callback_params
435
+ end
436
+
437
+ # The Finalizer marks this batch as complete, bumps any necessary counters, cleans up this Batch _if_ no callbacks were scheduled,
438
+ # and enqueues parent-Batch callbacks if needed.
439
+ logger.debug {"Run batch finalizer bid: #{bid} event: #{event} args: #{callback_args.inspect}"}
440
+ finalizer = Batch::Callback::Finalize.new
441
+ status = Status.new bid
442
+ finalizer.dispatch(status, opts)
443
+ end
444
+
445
+ def cleanup_redis(bid)
446
+ logger.debug {"Cleaning redis of batch #{bid}"}
447
+ redis do |r|
448
+ r.zrem("batches", bid)
449
+ r.zrem("BID-ROOT-bids", bid)
450
+ r.unlink(
451
+ "BID-#{bid}",
452
+ "BID-#{bid}-callbacks-complete",
453
+ "BID-#{bid}-callbacks-success",
454
+ "BID-#{bid}-failed",
455
+ "BID-#{bid}-dead",
456
+
457
+ "BID-#{bid}-batches-success",
458
+ "BID-#{bid}-batches-complete",
459
+ "BID-#{bid}-batches-failed",
460
+ "BID-#{bid}-bids",
461
+ "BID-#{bid}-jids",
462
+ "BID-#{bid}-pending_callbacks",
463
+ )
464
+ end
465
+ end
466
+
467
+ def delete_prematurely!(bid)
468
+ child_bids = redis do |r|
469
+ r.zrange("BID-#{bid}-bids", 0, -1)
470
+ end
471
+ child_bids.each do |cbid|
472
+ delete_prematurely!(cbid)
473
+ end
474
+ cleanup_redis(bid)
475
+ end
476
+
477
+ # Internal method to cleanup a Redis Hash and related keys
478
+ def cleanup_redis_index_for(key, suffixes = [""])
479
+ redis do |r|
480
+ if r.hget(k, "created_at").present?
481
+ r.multi do |r|
482
+ suffixes.each do |suffix|
483
+ r.expire(key + suffix, BID_EXPIRE_TTL)
484
+ end
485
+ end
486
+ false
487
+ else
488
+ r.multi do |r|
489
+ suffixes.each do |suffix|
490
+ r.unlink(key + suffix)
491
+ end
492
+ end
493
+ true
494
+ end
495
+ end
496
+ end
497
+
498
+ # Administrative/console method to cleanup expired batches from the WebUI
499
+ def cleanup_redis_index!
500
+ suffixes = ["", "-callbacks-complete", "-callbacks-success", "-failed", "-dead", "-batches-success", "-batches-complete", "-batches-failed", "-bids", "-jids", "-pending_callbacks"]
501
+
502
+ redis do |r|
503
+ cleanup_index = ->(index) {
504
+ r.zrangebyscore(index, "0", BID_EXPIRE_TTL.seconds.ago.to_i).each do |bid|
505
+ r.zrem(index, bid) if cleanup_redis_index_for("BID-#{bid}", suffixes)
506
+ end
507
+ }
508
+
509
+ cleanup_index.("BID-ROOT-bids")
510
+ cleanup_index.("batches")
511
+ end
512
+ end
513
+
514
+ def redis(&blk)
515
+ ::Joblin.redis(&blk)
516
+ end
517
+
518
+ def logger
519
+ ::Joblin.logger
520
+ end
521
+
522
+ def push_callbacks(args, queue)
523
+ Batch::Callback::worker_class.enqueue_all(args, queue)
524
+ end
525
+
526
+ def bid_hierarchy(bid, depth: 4, per_depth: 5, slice: nil)
527
+ args = [bid, depth, per_depth]
528
+ args << slice if slice
529
+ redis do |r|
530
+ BID_HIERARCHY.call(r, [], args)
531
+ end
532
+ end
533
+ end
534
+ end
535
+ end
536
+
537
+ require_relative './compat'
@@ -0,0 +1,135 @@
1
+ module Joblin::Batching
2
+ class Batch
3
+ module Callback
4
+ mattr_accessor :worker_class
5
+
6
+ VALID_CALLBACKS = %i[success complete death stagnated].freeze
7
+
8
+ module CallbackWorkerCommon
9
+ def perform(definition, event, opts, bid, parent_bid)
10
+ return unless VALID_CALLBACKS.include?(event.to_sym)
11
+
12
+ method = nil
13
+ target = :instance
14
+ clazz = definition
15
+ if clazz.is_a?(String)
16
+ if clazz.include?('#')
17
+ clazz, method = clazz.split("#")
18
+ elsif clazz.include?('.')
19
+ clazz, method = clazz.split(".")
20
+ target = :class
21
+ end
22
+ end
23
+
24
+ method ||= "on_#{event}"
25
+ status = Batch::Status.new(bid)
26
+
27
+ if clazz && object = Object.const_get(clazz)
28
+ target = target == :instance ? object.new : object
29
+ if target.respond_to?(method, true)
30
+ target.send(method, status, opts.with_indifferent_access)
31
+ else
32
+ Batch.logger.warn("Invalid callback method #{definition} - #{target.to_s} does not respond to #{method}")
33
+ end
34
+ else
35
+ Batch.logger.warn("Invalid callback method #{definition} - Class #{clazz} not found")
36
+ end
37
+ end
38
+ end
39
+
40
+ class Finalize
41
+ # The methods in this class are called after all same-named callbacks have been
42
+ # completed for the passed Batch.
43
+ # These methods mainly handle bubbling events up to the parent Batch
44
+ # You could say that they are the callbacks for callbacks.
45
+
46
+ def dispatch(status, opts)
47
+ bid = opts["bid"]
48
+ event = opts["event"].to_sym
49
+
50
+ Batch.logger.debug {"Finalize #{event} batch id: #{opts["bid"]}"}
51
+
52
+ batch_status = Status.new bid
53
+ send(event, bid, batch_status, batch_status.parent_bid)
54
+
55
+ Batch.redis do |r|
56
+ r.srem("BID-#{bid}-pending_callbacks", "#{event}-finalize")
57
+ end
58
+
59
+ if event == :success
60
+ if opts['origin'].present?
61
+ # This is a callback for a callback. In this case we need to check if we should cleanup the original bid.
62
+ origin_bid = opts['origin']['for_bid']
63
+ _, pending, success_ran = Batch.redis do |r|
64
+ r.multi do |r|
65
+ r.srem("BID-#{origin_bid}-pending_callbacks", opts['origin']['event'])
66
+ r.scard("BID-#{origin_bid}-pending_callbacks")
67
+ r.hget("BID-#{origin_bid}", "success")
68
+ end
69
+ end
70
+ Batch.cleanup_redis(origin_bid) if pending == 0 && success_ran == 'true'
71
+ end
72
+
73
+ if (Batch.redis {|r| r.scard("BID-#{bid}-pending_callbacks") }) == 0
74
+ Batch.cleanup_redis(bid)
75
+ end
76
+ end
77
+ end
78
+
79
+ def success(bid, status, parent_bid)
80
+ return unless parent_bid
81
+
82
+ Batch.with_callback_check(parent_bid) do |r|
83
+ r.sadd("BID-#{parent_bid}-batches-success", bid)
84
+ r.srem("BID-#{parent_bid}-batches-failed", bid)
85
+ r.sadd("BID-#{parent_bid}-batches-complete", bid)
86
+ end
87
+ end
88
+
89
+ def complete(bid, status, parent_bid)
90
+ return unless parent_bid
91
+
92
+ pending, children, success = Batch.redis do |r|
93
+ r.multi do |r|
94
+ r.hincrby("BID-#{bid}", "pending", 0)
95
+ r.hincrby("BID-#{bid}", "children", 0)
96
+ r.scard("BID-#{bid}-batches-success")
97
+ end
98
+ end
99
+
100
+ if !(pending.to_i.zero? && children == success)
101
+ # If batch was not successfull check and see if its parent is complete
102
+ # if the parent is complete we can trigger its complete callback.
103
+ #
104
+ # Otherwise, we don't want to to trigger the parent's :complete here (and
105
+ # we instead opt to have success tigger parent :complete) - this
106
+ # allows the success callback to add additional jobs to the parent batch
107
+ # before triggering :complete.
108
+
109
+ Batch.with_callback_check(parent_bid, except: [:success]) do |r|
110
+ r.sadd("BID-#{parent_bid}-batches-complete", bid)
111
+ r.sadd("BID-#{parent_bid}-batches-failed", bid)
112
+ end
113
+ end
114
+ end
115
+
116
+ def death(bid, status, parent_bid)
117
+ return unless parent_bid
118
+
119
+ # We only need to bubble the event here - other events (eg stagnation) will be checked and bubbled elsewhere.
120
+
121
+ Batch.enqueue_callbacks(:death, parent_bid)
122
+ end
123
+
124
+ def stagnated(bid, status, parent_bid)
125
+ return unless parent_bid
126
+
127
+ Batch.with_callback_check(parent_bid) do |r|
128
+ r.sadd("BID-#{parent_bid}-batches-stagnated", bid)
129
+ r.expire("BID-#{parent_bid}-batches-stagnated", BID_EXPIRE_TTL)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end