canvas_sync 0.16.4 → 0.17.0.beta4

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 (81) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +49 -137
  3. data/app/models/canvas_sync/sync_batch.rb +5 -0
  4. data/db/migrate/20170915210836_create_canvas_sync_job_log.rb +12 -31
  5. data/db/migrate/20180725155729_add_job_id_to_canvas_sync_job_logs.rb +4 -13
  6. data/db/migrate/20190916154829_add_fork_count_to_canvas_sync_job_logs.rb +3 -11
  7. data/db/migrate/20201018210836_create_canvas_sync_sync_batches.rb +11 -0
  8. data/lib/canvas_sync.rb +36 -118
  9. data/lib/canvas_sync/job.rb +5 -5
  10. data/lib/canvas_sync/job_batches/batch.rb +401 -0
  11. data/lib/canvas_sync/job_batches/batch_aware_job.rb +62 -0
  12. data/lib/canvas_sync/job_batches/callback.rb +157 -0
  13. data/lib/canvas_sync/job_batches/chain_builder.rb +220 -0
  14. data/lib/canvas_sync/job_batches/context_hash.rb +147 -0
  15. data/lib/canvas_sync/job_batches/jobs/base_job.rb +7 -0
  16. data/lib/canvas_sync/job_batches/jobs/concurrent_batch_job.rb +18 -0
  17. data/lib/canvas_sync/job_batches/jobs/serial_batch_job.rb +73 -0
  18. data/lib/canvas_sync/job_batches/sidekiq.rb +93 -0
  19. data/lib/canvas_sync/job_batches/status.rb +83 -0
  20. data/lib/canvas_sync/jobs/begin_sync_chain_job.rb +35 -0
  21. data/lib/canvas_sync/jobs/report_checker.rb +3 -6
  22. data/lib/canvas_sync/jobs/report_processor_job.rb +2 -5
  23. data/lib/canvas_sync/jobs/report_starter.rb +27 -19
  24. data/lib/canvas_sync/jobs/sync_accounts_job.rb +3 -5
  25. data/lib/canvas_sync/jobs/sync_admins_job.rb +2 -4
  26. data/lib/canvas_sync/jobs/sync_assignment_groups_job.rb +2 -4
  27. data/lib/canvas_sync/jobs/sync_assignments_job.rb +2 -4
  28. data/lib/canvas_sync/jobs/sync_context_module_items_job.rb +2 -4
  29. data/lib/canvas_sync/jobs/sync_context_modules_job.rb +2 -4
  30. data/lib/canvas_sync/jobs/sync_provisioning_report_job.rb +4 -34
  31. data/lib/canvas_sync/jobs/sync_roles_job.rb +2 -5
  32. data/lib/canvas_sync/jobs/sync_simple_table_job.rb +11 -32
  33. data/lib/canvas_sync/jobs/sync_submissions_job.rb +2 -4
  34. data/lib/canvas_sync/jobs/sync_terms_job.rb +25 -8
  35. data/lib/canvas_sync/misc_helper.rb +15 -0
  36. data/lib/canvas_sync/version.rb +1 -1
  37. data/spec/canvas_sync/canvas_sync_spec.rb +136 -153
  38. data/spec/canvas_sync/jobs/job_spec.rb +9 -17
  39. data/spec/canvas_sync/jobs/report_checker_spec.rb +1 -3
  40. data/spec/canvas_sync/jobs/report_processor_job_spec.rb +0 -3
  41. data/spec/canvas_sync/jobs/report_starter_spec.rb +19 -28
  42. data/spec/canvas_sync/jobs/sync_admins_job_spec.rb +1 -4
  43. data/spec/canvas_sync/jobs/sync_assignment_groups_job_spec.rb +2 -1
  44. data/spec/canvas_sync/jobs/sync_assignments_job_spec.rb +3 -2
  45. data/spec/canvas_sync/jobs/sync_context_module_items_job_spec.rb +3 -2
  46. data/spec/canvas_sync/jobs/sync_context_modules_job_spec.rb +3 -2
  47. data/spec/canvas_sync/jobs/sync_provisioning_report_job_spec.rb +3 -35
  48. data/spec/canvas_sync/jobs/sync_roles_job_spec.rb +1 -4
  49. data/spec/canvas_sync/jobs/sync_simple_table_job_spec.rb +5 -12
  50. data/spec/canvas_sync/jobs/sync_submissions_job_spec.rb +2 -1
  51. data/spec/canvas_sync/jobs/sync_terms_job_spec.rb +1 -4
  52. data/spec/dummy/app/models/account.rb +3 -0
  53. data/spec/dummy/app/models/pseudonym.rb +14 -0
  54. data/spec/dummy/app/models/submission.rb +1 -0
  55. data/spec/dummy/app/models/user.rb +1 -0
  56. data/spec/dummy/config/environments/test.rb +2 -0
  57. data/spec/dummy/db/migrate/20201016181346_create_pseudonyms.rb +24 -0
  58. data/spec/dummy/db/schema.rb +24 -4
  59. data/spec/dummy/db/test.sqlite3 +0 -0
  60. data/spec/dummy/log/development.log +1248 -0
  61. data/spec/dummy/log/test.log +43258 -0
  62. data/spec/job_batching/batch_aware_job_spec.rb +100 -0
  63. data/spec/job_batching/batch_spec.rb +372 -0
  64. data/spec/job_batching/callback_spec.rb +38 -0
  65. data/spec/job_batching/flow_spec.rb +91 -0
  66. data/spec/job_batching/integration/integration.rb +57 -0
  67. data/spec/job_batching/integration/nested.rb +88 -0
  68. data/spec/job_batching/integration/simple.rb +47 -0
  69. data/spec/job_batching/integration/workflow.rb +134 -0
  70. data/spec/job_batching/integration_helper.rb +48 -0
  71. data/spec/job_batching/sidekiq_spec.rb +124 -0
  72. data/spec/job_batching/status_spec.rb +92 -0
  73. data/spec/job_batching/support/base_job.rb +14 -0
  74. data/spec/job_batching/support/sample_callback.rb +2 -0
  75. data/spec/spec_helper.rb +17 -0
  76. data/spec/support/fixtures/reports/provisioning_csv_unzipped/courses.csv +3 -0
  77. data/spec/support/fixtures/reports/provisioning_csv_unzipped/users.csv +4 -0
  78. metadata +105 -14
  79. data/lib/canvas_sync/job_chain.rb +0 -57
  80. data/lib/canvas_sync/jobs/fork_gather.rb +0 -59
  81. data/spec/canvas_sync/jobs/fork_gather_spec.rb +0 -73
@@ -3,6 +3,8 @@ require "active_job"
3
3
  module CanvasSync
4
4
  # Inherit from this class to build a Job that will log to the canvas_sync_job_logs table
5
5
  class Job < ActiveJob::Base
6
+ attr_reader :job_log
7
+
6
8
  before_enqueue do |job|
7
9
  create_job_log(job)
8
10
  end
@@ -13,8 +15,6 @@ module CanvasSync
13
15
  @job_log.started_at = Time.now
14
16
  @job_log.save
15
17
 
16
- @job_chain = job.arguments[0] if job.arguments[0].is_a?(Hash) && job.arguments[0].include?(:jobs)
17
-
18
18
  begin
19
19
  block.call
20
20
  @job_log.status = JobLog::SUCCESS_STATUS
@@ -22,11 +22,11 @@ module CanvasSync
22
22
  @job_log.exception = "#{e.class}: #{e.message}"
23
23
  @job_log.backtrace = e.backtrace.join('\n')
24
24
  @job_log.status = JobLog::ERROR_STATUS
25
- if @job_chain&.[](:global_options)&.[](:on_failure)&.present?
25
+ if batch_context&.[](:on_failure)&.present?
26
26
  begin
27
- class_name, method = @job_chain[:global_options][:on_failure].split('.')
27
+ class_name, method = batch_context[:on_failure].split('.')
28
28
  klass = class_name.constantize
29
- klass.send(method.to_sym, e, job_chain: @job_chain, job_log: @job_log)
29
+ klass.send(method.to_sym, e, batch_context: batch_context, job_log: @job_log)
30
30
  rescue => e2
31
31
  @job_log.backtrace += "\n\nError Occurred while handling an Error: #{e2.class}: #{e2.message}"
32
32
  @job_log.backtrace += "\n" + e2.backtrace.join('\n')
@@ -0,0 +1,401 @@
1
+
2
+ begin
3
+ require 'sidekiq'
4
+ rescue LoadError
5
+ end
6
+
7
+ require_relative './batch_aware_job'
8
+ require_relative "./callback"
9
+ require_relative "./context_hash"
10
+ require_relative "./status"
11
+ Dir[File.dirname(__FILE__) + "/jobs/*.rb"].each { |file| require file }
12
+ require_relative "./chain_builder"
13
+
14
+ # Implement Job Batching similar to Sidekiq::Batch. Supports ActiveJob and Sidekiq, or a mix thereof.
15
+ # Much of this code is modifed/extended from https://github.com/breamware/sidekiq-batch
16
+
17
+ module CanvasSync
18
+ module JobBatches
19
+ class Batch
20
+ class NoBlockGivenError < StandardError; end
21
+
22
+ def self.batch_attr(key, read_only: true)
23
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
24
+ def #{key}=(value)
25
+ raise "#{key} is read-only once the batch has been started" if #{read_only.to_s} && (@initialized || @existing)
26
+ @#{key} = value
27
+ persist_bid_attr('#{key}', value)
28
+ end
29
+
30
+ def #{key}
31
+ return @#{key} if defined?(@#{key})
32
+ if (@initialized || @existing)
33
+ @#{key} = read_bid_attr('#{key}')
34
+ end
35
+ end
36
+ RUBY
37
+ end
38
+
39
+ delegate :redis, to: :class
40
+
41
+ BID_EXPIRE_TTL = 2_592_000
42
+
43
+ attr_reader :bid
44
+
45
+ def initialize(existing_bid = nil)
46
+ @bid = existing_bid || SecureRandom.urlsafe_base64(10)
47
+ @existing = !(!existing_bid || existing_bid.empty?) # Basically existing_bid.present?
48
+ @initialized = false
49
+ @bidkey = "BID-" + @bid.to_s
50
+ @pending_attrs = {}
51
+ @ready_to_queue = []
52
+ self.created_at = Time.now.utc.to_f unless @existing
53
+ end
54
+
55
+ batch_attr :description
56
+ batch_attr :created_at
57
+ batch_attr :callback_queue, read_only: false
58
+ batch_attr :callback_batch, read_only: false
59
+ batch_attr :allow_context_changes
60
+
61
+ def context
62
+ return @context if defined?(@context)
63
+
64
+ if (@initialized || @existing)
65
+ @context = ContextHash.new(bid)
66
+ else
67
+ @context = ContextHash.new(bid, {})
68
+ end
69
+ end
70
+
71
+ def context=(value)
72
+ raise "context is read-only once the batch has been started" if (@initialized || @existing) # && !allow_context_changes
73
+ raise "context must be a Hash" unless value.is_a?(Hash) || value.nil?
74
+ return nil if value.nil? && @context.nil?
75
+
76
+ value = {} if value.nil?
77
+ value = value.local if value.is_a?(ContextHash)
78
+
79
+ @context ||= ContextHash.new(bid, {})
80
+ @context.set_local(value)
81
+ # persist_bid_attr('context', JSON.unparse(@context.local))
82
+ end
83
+
84
+ def save_context_changes
85
+ @context&.save!
86
+ end
87
+
88
+ def on(event, callback, options = {})
89
+ return unless Callback::VALID_CALLBACKS.include?(event.to_s)
90
+ callback_key = "#{@bidkey}-callbacks-#{event}"
91
+ redis do |r|
92
+ r.multi do
93
+ r.sadd(callback_key, JSON.unparse({
94
+ callback: callback,
95
+ opts: options
96
+ }))
97
+ r.expire(callback_key, BID_EXPIRE_TTL)
98
+ end
99
+ end
100
+ end
101
+
102
+ def jobs
103
+ raise NoBlockGivenError unless block_given?
104
+
105
+ if !@existing && !@initialized
106
+ parent_bid = Thread.current[:batch]&.bid
107
+
108
+ redis do |r|
109
+ r.multi do
110
+ r.hset(@bidkey, "parent_bid", parent_bid.to_s) if parent_bid
111
+ r.expire(@bidkey, BID_EXPIRE_TTL)
112
+ end
113
+ end
114
+
115
+ flush_pending_attrs
116
+ @context&.save!
117
+
118
+ @initialized = true
119
+ end
120
+
121
+ @ready_to_queue = []
122
+
123
+ begin
124
+ parent = Thread.current[:batch]
125
+ Thread.current[:batch] = self
126
+ yield
127
+ ensure
128
+ Thread.current[:batch] = parent
129
+ end
130
+
131
+ redis do |r|
132
+ r.multi do
133
+ if parent_bid
134
+ r.hincrby("BID-#{parent_bid}", "children", 1)
135
+ r.hincrby("BID-#{parent_bid}", "total", @ready_to_queue.size)
136
+ r.expire("BID-#{parent_bid}", BID_EXPIRE_TTL)
137
+ end
138
+
139
+ r.hincrby(@bidkey, "pending", @ready_to_queue.size)
140
+ r.hincrby(@bidkey, "total", @ready_to_queue.size)
141
+ r.expire(@bidkey, BID_EXPIRE_TTL)
142
+
143
+ if @ready_to_queue.size > 0
144
+ r.sadd(@bidkey + "-jids", @ready_to_queue)
145
+ r.expire(@bidkey + "-jids", BID_EXPIRE_TTL)
146
+ end
147
+ end
148
+ end
149
+
150
+ @ready_to_queue
151
+ end
152
+
153
+ def increment_job_queue(jid)
154
+ @ready_to_queue << jid
155
+ end
156
+
157
+ def invalidate_all
158
+ redis do |r|
159
+ r.setex("invalidated-bid-#{bid}", BID_EXPIRE_TTL, 1)
160
+ end
161
+ end
162
+
163
+ def parent_bid
164
+ redis do |r|
165
+ r.hget(@bidkey, "parent_bid")
166
+ end
167
+ end
168
+
169
+ def parent
170
+ if parent_bid
171
+ Batch.new(parent_bid)
172
+ end
173
+ end
174
+
175
+ def valid?(batch = self)
176
+ valid = !redis { |r| r.exists?("invalidated-bid-#{batch.bid}") }
177
+ batch.parent ? valid && valid?(batch.parent) : valid
178
+ end
179
+
180
+ # Any Batches or Jobs created in the given block won't be assocaiated to the current batch
181
+ def self.without_batch
182
+ parent = Thread.current[:batch]
183
+ Thread.current[:batch] = nil
184
+ yield
185
+ ensure
186
+ Thread.current[:batch] = parent
187
+ end
188
+
189
+ private
190
+
191
+ def persist_bid_attr(attribute, value)
192
+ if @initialized || @existing
193
+ redis do |r|
194
+ r.multi do
195
+ r.hset(@bidkey, attribute, value)
196
+ r.expire(@bidkey, BID_EXPIRE_TTL)
197
+ end
198
+ end
199
+ else
200
+ @pending_attrs[attribute] = value
201
+ end
202
+ end
203
+
204
+ def read_bid_attr(attribute)
205
+ redis do |r|
206
+ r.hget(@bidkey, attribute)
207
+ end
208
+ end
209
+
210
+ def flush_pending_attrs
211
+ redis do |r|
212
+ r.mapped_hmset(@bidkey, @pending_attrs)
213
+ end
214
+ @pending_attrs = {}
215
+ end
216
+
217
+ class << self
218
+ def process_failed_job(bid, jid)
219
+ _, pending, failed, children, complete, parent_bid = redis do |r|
220
+ r.multi do
221
+ r.sadd("BID-#{bid}-failed", jid)
222
+
223
+ r.hincrby("BID-#{bid}", "pending", 0)
224
+ r.scard("BID-#{bid}-failed")
225
+ r.hincrby("BID-#{bid}", "children", 0)
226
+ r.scard("BID-#{bid}-batches-complete")
227
+ r.hget("BID-#{bid}", "parent_bid")
228
+
229
+ r.expire("BID-#{bid}-failed", BID_EXPIRE_TTL)
230
+ end
231
+ end
232
+
233
+ # if the batch failed, and has a parent, update the parent to show one pending and failed job
234
+ if parent_bid
235
+ redis do |r|
236
+ r.multi do
237
+ r.hincrby("BID-#{parent_bid}", "pending", 1)
238
+ r.sadd("BID-#{parent_bid}-failed", jid)
239
+ r.expire("BID-#{parent_bid}-failed", BID_EXPIRE_TTL)
240
+ end
241
+ end
242
+ end
243
+
244
+ if pending.to_i == failed.to_i && children == complete
245
+ enqueue_callbacks(:complete, bid)
246
+ end
247
+ end
248
+
249
+ def process_dead_job(bid, jid)
250
+ _, failed, children, complete, parent_bid = redis do |r|
251
+ r.multi do
252
+ r.sadd("BID-#{bid}-dead", jid)
253
+
254
+ r.scard("BID-#{bid}-dead")
255
+ r.hincrby("BID-#{bid}", "children", 0)
256
+ r.scard("BID-#{bid}-batches-complete")
257
+ r.hget("BID-#{bid}", "parent_bid")
258
+
259
+ r.expire("BID-#{bid}-dead", BID_EXPIRE_TTL)
260
+ end
261
+ end
262
+
263
+ if parent_bid
264
+ redis do |r|
265
+ r.multi do
266
+ r.sadd("BID-#{parent_bid}-dead", jid)
267
+ r.expire("BID-#{parent_bid}-dead", BID_EXPIRE_TTL)
268
+ end
269
+ end
270
+ end
271
+
272
+ enqueue_callbacks(:dead, bid)
273
+ end
274
+
275
+ def process_successful_job(bid, jid)
276
+ _, failed, pending, children, complete, success, total, parent_bid = redis do |r|
277
+ r.multi do
278
+ r.srem("BID-#{bid}-failed", jid)
279
+
280
+ r.scard("BID-#{bid}-failed")
281
+ r.hincrby("BID-#{bid}", "pending", -1)
282
+ r.hincrby("BID-#{bid}", "children", 0)
283
+ r.scard("BID-#{bid}-batches-complete")
284
+ r.scard("BID-#{bid}-batches-success")
285
+ r.hget("BID-#{bid}", "total")
286
+ r.hget("BID-#{bid}", "parent_bid")
287
+
288
+ r.srem("BID-#{bid}-jids", jid)
289
+ r.expire("BID-#{bid}", BID_EXPIRE_TTL)
290
+ end
291
+ end
292
+
293
+ all_success = pending.to_i.zero? && children == success
294
+ # if complete or successfull call complete callback (the complete callback may then call successful)
295
+ if (pending.to_i == failed.to_i && children == complete) || all_success
296
+ enqueue_callbacks(:complete, bid)
297
+ enqueue_callbacks(:success, bid) if all_success
298
+ end
299
+ end
300
+
301
+ def enqueue_callbacks(event, bid)
302
+ batch_key = "BID-#{bid}"
303
+ callback_key = "#{batch_key}-callbacks-#{event}"
304
+ already_processed, _, callbacks, queue, parent_bid, callback_batch = redis do |r|
305
+ r.multi do
306
+ r.hget(batch_key, event)
307
+ r.hset(batch_key, event, true)
308
+ r.smembers(callback_key)
309
+ r.hget(batch_key, "callback_queue")
310
+ r.hget(batch_key, "parent_bid")
311
+ r.hget(batch_key, "callback_batch")
312
+ end
313
+ end
314
+
315
+ return if already_processed == 'true'
316
+
317
+ queue ||= "default"
318
+ parent_bid = !parent_bid || parent_bid.empty? ? nil : parent_bid # Basically parent_bid.blank?
319
+ callback_args = callbacks.reduce([]) do |memo, jcb|
320
+ cb = JSON.load(jcb)
321
+ memo << [cb['callback'], event.to_s, cb['opts'], bid, parent_bid]
322
+ end
323
+
324
+ opts = {"bid" => bid, "event" => event}
325
+
326
+ # Run callback batch finalize synchronously
327
+ if callback_batch
328
+ # Extract opts from cb_args or use current
329
+ # Pass in stored event as callback finalize is processed on complete event
330
+ cb_opts = callback_args.first&.at(2) || opts
331
+
332
+ logger.debug {"Run callback batch bid: #{bid} event: #{event} args: #{callback_args.inspect}"}
333
+ # Finalize now
334
+ finalizer = Batch::Callback::Finalize.new
335
+ status = Status.new bid
336
+ finalizer.dispatch(status, cb_opts)
337
+
338
+ return
339
+ end
340
+
341
+ logger.debug {"Enqueue callback bid: #{bid} event: #{event} args: #{callback_args.inspect}"}
342
+
343
+ if callback_args.empty?
344
+ # Finalize now
345
+ finalizer = Batch::Callback::Finalize.new
346
+ status = Status.new bid
347
+ finalizer.dispatch(status, opts)
348
+ else
349
+ # Otherwise finalize in sub batch complete callback
350
+ cb_batch = self.new
351
+ cb_batch.callback_batch = true
352
+ logger.debug {"Adding callback batch: #{cb_batch.bid} for batch: #{bid}"}
353
+ cb_batch.on(:complete, "#{Batch::Callback::Finalize.to_s}#dispatch", opts)
354
+ cb_batch.jobs do
355
+ push_callbacks callback_args, queue
356
+ end
357
+ end
358
+ end
359
+
360
+ def cleanup_redis(bid)
361
+ logger.debug {"Cleaning redis of batch #{bid}"}
362
+ redis do |r|
363
+ r.del(
364
+ "BID-#{bid}",
365
+ "BID-#{bid}-callbacks-complete",
366
+ "BID-#{bid}-callbacks-success",
367
+ "BID-#{bid}-failed",
368
+
369
+ "BID-#{bid}-batches-success",
370
+ "BID-#{bid}-batches-complete",
371
+ "BID-#{bid}-batches-failed",
372
+ "BID-#{bid}-jids",
373
+ )
374
+ end
375
+ end
376
+
377
+ def redis(*args, &blk)
378
+ defined?(::Sidekiq) ? ::Sidekiq.redis(*args, &blk) : nil # TODO
379
+ end
380
+
381
+ def logger
382
+ defined?(::Sidekiq) ? ::Sidekiq.logger : Rails.logger
383
+ end
384
+
385
+ private
386
+
387
+ def push_callbacks(args, queue)
388
+ Batch::Callback::Worker.enqueue_all(args, queue)
389
+ end
390
+ end
391
+ end
392
+
393
+ ActiveJob::Base.include BatchAwareJob
394
+ end
395
+ end
396
+
397
+ # Automatically integrate with Sidekiq if it is present.
398
+ if defined?(::Sidekiq)
399
+ require_relative './sidekiq'
400
+ CanvasSync::JobBatches::Sidekiq.configure
401
+ end
@@ -0,0 +1,62 @@
1
+ module CanvasSync
2
+ module JobBatches
3
+ module BatchAwareJob
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ around_perform do |job, block|
8
+ if (@bid) # This _must_ be @bid - not just bid
9
+ prev_batch = Thread.current[:batch]
10
+ begin
11
+ Thread.current[:batch] = Batch.new(@bid)
12
+ block.call
13
+ batch&.save_context_changes
14
+ Batch.process_successful_job(@bid, job_id)
15
+ rescue
16
+ Batch.process_failed_job(@bid, job_id)
17
+ raise
18
+ ensure
19
+ Thread.current[:batch] = prev_batch
20
+ end
21
+ else
22
+ block.call
23
+ end
24
+ end
25
+
26
+ around_enqueue do |job, block|
27
+ if (batch = Thread.current[:batch])
28
+ batch.increment_job_queue(job_id) if (@bid = batch.bid)
29
+ end
30
+ block.call
31
+ end
32
+ end
33
+
34
+ def bid
35
+ @bid || Thread.current[:batch]&.bid
36
+ end
37
+
38
+ def batch
39
+ Thread.current[:batch]
40
+ end
41
+
42
+ def batch_context
43
+ batch&.context || {}
44
+ end
45
+
46
+ def valid_within_batch?
47
+ batch.valid?
48
+ end
49
+
50
+ def serialize
51
+ super.tap do |data|
52
+ data['batch_id'] = @bid # This _must_ be @bid - not just bid
53
+ end
54
+ end
55
+
56
+ def deserialize(data)
57
+ super
58
+ @bid = data['batch_id']
59
+ end
60
+ end
61
+ end
62
+ end