cloudtasker 0.11.0 → 0.12.rc5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f4e53fe9c99a8c6a74e064d7b7c562cdb671682aaed9bb6112a77c7fd453124
4
- data.tar.gz: 3b2e6aeca4fde352acd7b0db6fe469221514bf7fd753c07c3d9540d881bf1c2f
3
+ metadata.gz: a1f63a9bfefe90d0cfa0d6b567098ec72efe150894fbd878daa72fe934d27a25
4
+ data.tar.gz: 347d6358120bd83f116b569fafcd0313a96d8c34a4f6222ca20b0dedda866098
5
5
  SHA512:
6
- metadata.gz: 4be2ce9466e7ffd2cc274d57d79eedad96ae56d699632f88bfdf6384413aa9bfa19dff8a124926bd2b41229ce53f3126b2bd04646cc9125136bc93fdffb0642a
7
- data.tar.gz: f6a3f7fa152f5ebedbe25945e86538691ee658c5bc02b840fd3d1f337ba0fcd78db6a84c52bcd6ade01af51335ee81d457152bfa9767d3ac9541b5d31ef48126
6
+ metadata.gz: a4ecf9ad17d612133653f70e7691fc746742c3ca62eb8f67c09bdc1992776391e755a5c674b97d0339bd74514958299062610d448b089fe5995dd6abe5756021
7
+ data.tar.gz: 231f0ba89cf89db4bdb138a1d34d10688161d156c9898da7a067783f75f930b9318a08dfdf240188e23dfa3a0e9dd59b38b1b235b5ab5038eb6727a244877017
data/.rubocop.yml CHANGED
@@ -6,13 +6,15 @@ AllCops:
6
6
  - 'vendor/**/*'
7
7
 
8
8
  Metrics/ClassLength:
9
- Max: 150
9
+ Max: 200
10
10
 
11
11
  Metrics/ModuleLength:
12
12
  Max: 150
13
13
 
14
14
  Metrics/AbcSize:
15
15
  Max: 20
16
+ Exclude:
17
+ - 'spec/support/*'
16
18
 
17
19
  Metrics/LineLength:
18
20
  Max: 120
@@ -47,4 +49,9 @@ Metrics/ParameterLists:
47
49
  CountKeywordArgs: false
48
50
 
49
51
  RSpec/MessageSpies:
50
- Enabled: false
52
+ Enabled: false
53
+
54
+ RSpec/MultipleExpectations:
55
+ Exclude:
56
+ - 'examples/**/*'
57
+ - 'spec/integration/**/*'
data/CHANGELOG.md CHANGED
@@ -1,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## [v0.11.0](https://github.com/keypup-io/cloudtasker/tree/v0.11.0) (2020-11-23)
3
+ ## Latest RC [v0.12.rc5](https://github.com/keypup-io/cloudtasker/tree/v0.12.rc5) (2021-03-30)
4
+
5
+ [Full Changelog](https://github.com/keypup-io/cloudtasker/compare/v0.11.0...v0.12.rc5)
6
+
7
+ **Improvements:**
8
+ - ActiveJob: do not double log errors (ActiveJob has its own error logging)
9
+ - Error logging: Use worker logger so as to include context (job args etc.)
10
+ - Error logging: Do not log exception and stack trace separately, combine them instead.
11
+ - Batch callbacks: Retry jobs when completion callback fails
12
+ - Redis: Use Redis Sets instead of key pattern matching for listing methods (Cron jobs and Local Server)
13
+ - Batch progress: restrict calculation to direct children by default. Allow depth to be specified. Calculating progress using all tree jobs created significant delays on large batches.
14
+
15
+ **Fixed bugs:**
16
+ - Retries: Enforce job retry limit on job processing. There was an edge case where jobs could be retried indefinitely on batch callback errors.
17
+
18
+ ## [v0.11.0](https://github.com/keypup-io/cloudtasker/tree/v0.11.0) (2021-03-11)
4
19
 
5
20
  [Full Changelog](https://github.com/keypup-io/cloudtasker/compare/v0.10.0...v0.11.0)
6
21
 
data/README.md CHANGED
@@ -12,7 +12,7 @@ Cloudtasker also provides optional modules for running [cron jobs](docs/CRON_JOB
12
12
 
13
13
  A local processing server is also available for development. This local server processes jobs in lieu of Cloud Tasks and allows you to work offline.
14
14
 
15
- **Maturity**: This gem is production-ready. We at Keypup have already processed millions of jobs using Cloudtasker and all related extensions (cron, batch and unique jobs). I'm waiting till the end of 2020 before releasing the official `v1.0.0` in case we've missed any edge-case bug.
15
+ **Maturity**: This gem is production-ready. We at Keypup have already processed millions of jobs using Cloudtasker and all related extensions (cron, batch and unique jobs). We are planning to release the official `v1.0.0` somewhere in 2021, in case we've missed any edge-case bug.
16
16
 
17
17
  ## Summary
18
18
 
@@ -136,7 +136,7 @@ That's it! Your job was picked up by the Cloudtasker local server and sent for p
136
136
  Now jump to the next section to configure your app to use Google Cloud Tasks as a backend.
137
137
 
138
138
  ## Get started with Rails & ActiveJob
139
- **Note**: ActiveJob is supported since `0.11.0`
139
+ **Note**: ActiveJob is supported since `0.11.0`
140
140
  **Note**: Cloudtasker extensions (cron, batch and unique jobs) are not available when using cloudtasker via ActiveJob.
141
141
 
142
142
  Cloudtasker is pre-integrated with ActiveJob. Follow the steps below to get started.
@@ -19,32 +19,19 @@ module Cloudtasker
19
19
  # Process payload
20
20
  WorkerHandler.execute_from_payload!(payload)
21
21
  head :no_content
22
- rescue DeadWorkerError, MissingWorkerArgumentsError => e
22
+ rescue DeadWorkerError, MissingWorkerArgumentsError
23
23
  # 205: job will NOT be retried
24
- log_error(e)
25
24
  head :reset_content
26
- rescue InvalidWorkerError => e
25
+ rescue InvalidWorkerError
27
26
  # 404: Job will be retried
28
- log_error(e)
29
27
  head :not_found
30
- rescue StandardError => e
31
- # 404: Job will be retried
32
- log_error(e)
28
+ rescue StandardError
29
+ # 422: Job will be retried
33
30
  head :unprocessable_entity
34
31
  end
35
32
 
36
33
  private
37
34
 
38
- #
39
- # Log an error via cloudtasker logger.
40
- #
41
- # @param [Exception] e The error to log
42
- #
43
- def log_error(error)
44
- Cloudtasker.logger.error(error)
45
- Cloudtasker.logger.error(error.backtrace.join("\n"))
46
- end
47
-
48
35
  #
49
36
  # Parse the request body and return the actual job
50
37
  # payload.
data/docs/BATCH_JOBS.md CHANGED
@@ -84,8 +84,29 @@ You can access progression statistics in callback using `batch.progress`. See th
84
84
  E.g.
85
85
  ```ruby
86
86
  def on_batch_node_complete(_child_job)
87
- logger.info("Total: #{batch.progress.total}")
88
- logger.info("Completed: #{batch.progress.completed}")
89
- logger.info("Progress: #{batch.progress.percent.to_i}%")
87
+ progress = batch.progress
88
+ logger.info("Total: #{progress.total}")
89
+ logger.info("Completed: #{progress.completed}")
90
+ logger.info("Progress: #{progress.percent.to_i}%")
91
+ end
92
+ ```
93
+
94
+ **Since:** `v0.12.rc5`
95
+ By default the `progress` method only considers the direct child jobs to evaluate the batch progress. You can pass `depth: somenumber` to the `progress` method to calculate the actual batch progress in a more granular way. Be careful however that this method recursively calculates progress on the sub-batches and is therefore expensive.
96
+
97
+ E.g.
98
+ ```ruby
99
+ def on_batch_node_complete(_child_job)
100
+ # Considers the children for batch progress calculation
101
+ progress_0 = batch.progress # same as batch.progress(depth: 0)
102
+
103
+ # Considers the children and grand-children for batch progress calculation
104
+ progress_1 = batch.progress(depth: 1)
105
+
106
+ # Considers the children, grand-children and grand-grand-children for batch progress calculation
107
+ progress_2 = batch.progress(depth: 3)
108
+
109
+ logger.info("Progress: #{progress_1.percent.to_i}%")
110
+ logger.info("Progress: #{progress_2.percent.to_i}%")
90
111
  end
91
112
  ```
@@ -23,14 +23,12 @@ module Cloudtasker
23
23
  #
24
24
  # Return a namespaced key.
25
25
  #
26
- # @param [String, Symbol] val The key to namespace
26
+ # @param [String, Symbol, nil] val The key to namespace
27
27
  #
28
28
  # @return [String] The namespaced key.
29
29
  #
30
- def self.key(val)
31
- return nil if val.nil?
32
-
33
- [to_s.underscore, val.to_s].join('/')
30
+ def self.key(val = nil)
31
+ [to_s.underscore, val].compact.map(&:to_s).join('/')
34
32
  end
35
33
 
36
34
  #
@@ -39,9 +37,17 @@ module Cloudtasker
39
37
  # @return [Array<Cloudtasker::Backend::RedisTask>] All the tasks.
40
38
  #
41
39
  def self.all
42
- redis.search(key('*')).map do |gid|
43
- payload = redis.fetch(gid)
44
- new(payload.merge(id: gid.sub(key(''), '')))
40
+ if redis.exists?(key)
41
+ # Use Schedule Set if available
42
+ redis.smembers(key).map { |id| find(id) }
43
+ else
44
+ # Fallback to redis key matching and migrate tasks
45
+ # to use Task Set instead.
46
+ redis.search(key('*')).map do |gid|
47
+ task_id = gid.sub(key(''), '')
48
+ redis.sadd(key, task_id)
49
+ find(task_id)
50
+ end
45
51
  end
46
52
  end
47
53
 
@@ -82,6 +88,7 @@ module Cloudtasker
82
88
 
83
89
  # Save job
84
90
  redis.write(key(id), payload)
91
+ redis.sadd(key, id)
85
92
  new(payload.merge(id: id))
86
93
  end
87
94
 
@@ -105,6 +112,7 @@ module Cloudtasker
105
112
  # @param [String] id The task id.
106
113
  #
107
114
  def self.delete(id)
115
+ redis.srem(key, id)
108
116
  redis.del(key(id))
109
117
  end
110
118
 
@@ -170,13 +178,14 @@ module Cloudtasker
170
178
  schedule_time: (Time.now + interval).to_i,
171
179
  queue: queue
172
180
  )
181
+ redis.sadd(self.class.key, id)
173
182
  end
174
183
 
175
184
  #
176
185
  # Remove the task from the queue.
177
186
  #
178
187
  def destroy
179
- redis.del(gid)
188
+ self.class.delete(id)
180
189
  end
181
190
 
182
191
  #
@@ -13,6 +13,10 @@ module Cloudtasker
13
13
  # List of statuses triggering a completion callback
14
14
  COMPLETION_STATUSES = %w[completed dead].freeze
15
15
 
16
+ # These callbacks do not need to raise errors on their own
17
+ # because the jobs will be either retried or dropped
18
+ IGNORED_ERRORED_CALLBACKS = %i[on_child_error on_child_dead].freeze
19
+
16
20
  #
17
21
  # Return the cloudtasker redis client
18
22
  #
@@ -250,8 +254,8 @@ module Cloudtasker
250
254
  end
251
255
 
252
256
  #
253
- # Run worker callback in a controlled environment to
254
- # avoid interruption of the callback flow.
257
+ # Run worker callback. The error and dead callbacks get
258
+ # silenced should they raise an error.
255
259
  #
256
260
  # @param [String, Symbol] callback The callback to run.
257
261
  # @param [Array<any>] *args The callback arguments.
@@ -261,9 +265,14 @@ module Cloudtasker
261
265
  def run_worker_callback(callback, *args)
262
266
  worker.try(callback, *args)
263
267
  rescue StandardError => e
264
- Cloudtasker.logger.error("Error running callback #{callback}: #{e}")
265
- Cloudtasker.logger.error(e.backtrace.join("\n"))
266
- nil
268
+ # There is no point in retrying jobs due to failure callbacks failing
269
+ # Only completion callbacks will trigger a re-run of the job because
270
+ # these do matter for batch completion
271
+ raise(e) unless IGNORED_ERRORED_CALLBACKS.include?(callback)
272
+
273
+ # Log error instead
274
+ worker.logger.error(e)
275
+ worker.logger.error("Callback #{callback} failed to run. Skipping to preserve error flow.")
267
276
  end
268
277
 
269
278
  #
@@ -275,7 +284,7 @@ module Cloudtasker
275
284
 
276
285
  # Propagate event
277
286
  parent_batch&.on_child_complete(self, status)
278
- ensure
287
+
279
288
  # The batch tree is complete. Cleanup the tree.
280
289
  cleanup unless parent_batch
281
290
  end
@@ -338,13 +347,20 @@ module Cloudtasker
338
347
  #
339
348
  # @return [Cloudtasker::Batch::BatchProgress] The batch progress.
340
349
  #
341
- def progress
350
+ def progress(depth: 0)
351
+ depth = depth.to_i
352
+
342
353
  # Capture batch state
343
354
  state = batch_state
344
355
 
345
- # Sum batch progress of current batch and all sub-batches
356
+ # Return immediately if we do not need to go down the tree
357
+ return BatchProgress.new(state) if depth <= 0
358
+
359
+ # Sum batch progress of current batch and sub-batches up to the specified
360
+ # depth
346
361
  state.to_h.reduce(BatchProgress.new(state)) do |memo, (child_id, child_status)|
347
- memo + (self.class.find(child_id)&.progress || BatchProgress.new(child_id => child_status))
362
+ memo + (self.class.find(child_id)&.progress(depth: depth - 1) ||
363
+ BatchProgress.new(child_id => child_status))
348
364
  end
349
365
  end
350
366
 
@@ -21,14 +21,12 @@ module Cloudtasker
21
21
  #
22
22
  # Return a namespaced key.
23
23
  #
24
- # @param [String, Symbol] val The key to namespace
24
+ # @param [String, Symbol, nil] val The key to namespace
25
25
  #
26
26
  # @return [String] The namespaced key.
27
27
  #
28
- def self.key(val)
29
- return nil if val.nil?
30
-
31
- [to_s.underscore, val.to_s].join('/')
28
+ def self.key(val = nil)
29
+ [to_s.underscore, val].compact.map(&:to_s).join('/')
32
30
  end
33
31
 
34
32
  #
@@ -37,8 +35,17 @@ module Cloudtasker
37
35
  # @return [Array<Cloudtasker::Batch::Schedule>] The list of stored schedules.
38
36
  #
39
37
  def self.all
40
- redis.search(key('*')).map do |gid|
41
- find(gid.sub(key(''), ''))
38
+ if redis.exists?(key)
39
+ # Use Schedule Set if available
40
+ redis.smembers(key).map { |id| find(id) }
41
+ else
42
+ # Fallback to redis key matching and migrate schedules
43
+ # to use Schedule Set instead.
44
+ redis.search(key('*')).map do |gid|
45
+ schedule_id = gid.sub(key(''), '')
46
+ redis.sadd(key, schedule_id)
47
+ find(schedule_id)
48
+ end
42
49
  end
43
50
  end
44
51
 
@@ -90,7 +97,7 @@ module Cloudtasker
90
97
  end
91
98
 
92
99
  #
93
- # Destroy a schedule by id.
100
+ # Delete a schedule by id.
94
101
  #
95
102
  # @param [String] id The schedule id.
96
103
  #
@@ -101,6 +108,7 @@ module Cloudtasker
101
108
 
102
109
  # Delete task and stored schedule
103
110
  CloudTask.delete(schedule.task_id) if schedule.task_id
111
+ redis.srem(key, schedule.id)
104
112
  redis.del(schedule.gid)
105
113
  end
106
114
  end
@@ -270,6 +278,7 @@ module Cloudtasker
270
278
 
271
279
  # Save schedule
272
280
  config_was_changed = config_changed?
281
+ redis.sadd(self.class.key, id)
273
282
  redis.write(gid, to_h)
274
283
 
275
284
  # Stop there if backend does not need update
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cloudtasker
4
- VERSION = '0.11.0'
4
+ VERSION = '0.12.rc5'
5
5
  end
@@ -311,13 +311,25 @@ module Cloudtasker
311
311
  end
312
312
 
313
313
  #
314
- # Return true if the job has excceeded its maximum number
315
- # of retries
314
+ # Return true if the job must declared dead upon raising
315
+ # an error.
316
+ #
317
+ # @return [Boolean] True if the job must die on error.
318
+ #
319
+ def job_must_die?
320
+ job_retries >= job_max_retries
321
+ end
322
+
323
+ #
324
+ # Return true if the job has strictly excceeded its maximum number
325
+ # of retries.
326
+ #
327
+ # Used a preemptive filter when running the job.
316
328
  #
317
329
  # @return [Boolean] True if the job is dead
318
330
  #
319
331
  def job_dead?
320
- job_retries >= job_max_retries
332
+ job_retries > job_max_retries
321
333
  end
322
334
 
323
335
  #
@@ -332,11 +344,35 @@ module Cloudtasker
332
344
  (perform_ended_at - perform_started_at).ceil(3)
333
345
  end
334
346
 
347
+ #
348
+ # Run worker callback.
349
+ #
350
+ # @param [String, Symbol] callback The callback to run.
351
+ # @param [Array<any>] *args The callback arguments.
352
+ #
353
+ # @return [any] The callback return value
354
+ #
355
+ def run_callback(callback, *args)
356
+ try(callback, *args)
357
+ end
358
+
335
359
  #=============================
336
360
  # Private
337
361
  #=============================
338
362
  private
339
363
 
364
+ #
365
+ # Flag the worker as dead by invoking the on_dead hook
366
+ # and raising a DeadWorkerError
367
+ #
368
+ # @param [Exception, nil] error An optional exception to be passed to the DeadWorkerError.
369
+ #
370
+ def flag_as_dead(error = nil)
371
+ run_callback(:on_dead, error || DeadWorkerError.new)
372
+ ensure
373
+ raise(DeadWorkerError, error)
374
+ end
375
+
340
376
  #
341
377
  # Execute the worker perform method through the middleware chain.
342
378
  #
@@ -346,6 +382,9 @@ module Cloudtasker
346
382
  self.perform_started_at = Time.now
347
383
 
348
384
  Cloudtasker.config.server_middleware.invoke(self) do
385
+ # Immediately abort the job if it is already dead
386
+ flag_as_dead if job_dead?
387
+
349
388
  begin
350
389
  # Abort if arguments are missing. This may happen with redis arguments storage
351
390
  # if Cloud Tasks times out on a job but the job still succeeds
@@ -356,12 +395,11 @@ module Cloudtasker
356
395
  # Perform the job
357
396
  perform(*job_args)
358
397
  rescue StandardError => e
359
- try(:on_error, e)
360
- return raise(e) unless job_dead?
398
+ run_callback(:on_error, e)
399
+ return raise(e) unless job_must_die?
361
400
 
362
401
  # Flag job as dead
363
- try(:on_dead, e)
364
- raise(DeadWorkerError, e)
402
+ flag_as_dead(e)
365
403
  end
366
404
  end
367
405
  ensure
@@ -45,6 +45,28 @@ module Cloudtasker
45
45
  end
46
46
  end
47
47
 
48
+ #
49
+ # Log error on execution failure.
50
+ #
51
+ # @param [Cloudtasker::Worker, nil] worker The worker.
52
+ # @param [Exception] error The error to log.
53
+ #
54
+ # @void
55
+ #
56
+ def self.log_execution_error(worker, error)
57
+ # ActiveJob has its own error logging. No need to double log the error.
58
+ # Note: we use string matching instead of class matching as
59
+ # ActiveJob::QueueAdapters::CloudtaskerAdapter::JobWrapper might not be loaded
60
+ return if worker.class.to_s =~ /^ActiveJob::/
61
+
62
+ # Choose logger to use based on context
63
+ # Worker will be nil on InvalidWorkerError - in that case we use generic logging
64
+ logger = worker&.logger || Cloudtasker.logger
65
+
66
+ # Log error
67
+ logger.error(error)
68
+ end
69
+
48
70
  #
49
71
  # Execute a task worker from a task payload
50
72
  #
@@ -88,6 +110,10 @@ module Cloudtasker
88
110
  rescue DeadWorkerError, MissingWorkerArgumentsError => e
89
111
  # Delete stored args payload if job is dead
90
112
  redis.expire(args_payload_key, ARGS_PAYLOAD_CLEANUP_TTL) if args_payload_key
113
+ log_execution_error(worker, e)
114
+ raise(e)
115
+ rescue StandardError => e
116
+ log_execution_error(worker, e)
91
117
  raise(e)
92
118
  end
93
119
 
@@ -51,6 +51,26 @@ module Cloudtasker
51
51
  Cloudtasker.logger
52
52
  end
53
53
 
54
+ #
55
+ # Format the log message as string.
56
+ #
57
+ # @param [Object] msg The log message or object.
58
+ #
59
+ # @return [String] The formatted message
60
+ #
61
+ def formatted_message_as_string(msg)
62
+ # Format message
63
+ msg_content = if msg.is_a?(Exception)
64
+ [msg.inspect, msg.backtrace].flatten(1).join("\n")
65
+ elsif msg.is_a?(String)
66
+ msg
67
+ else
68
+ msg.inspect
69
+ end
70
+
71
+ "[Cloudtasker][#{worker.class}][#{worker.job_id}] #{msg_content}"
72
+ end
73
+
54
74
  #
55
75
  # Format main log message.
56
76
  #
@@ -59,7 +79,12 @@ module Cloudtasker
59
79
  # @return [String] The formatted log message
60
80
  #
61
81
  def formatted_message(msg)
62
- "[Cloudtasker][#{worker.class}][#{worker.job_id}] #{msg}"
82
+ if msg.is_a?(String)
83
+ formatted_message_as_string(msg)
84
+ else
85
+ # Delegate object formatting to logger
86
+ msg
87
+ end
63
88
  end
64
89
 
65
90
  #
@@ -147,7 +172,9 @@ module Cloudtasker
147
172
  # ActiveSupport::Logger does not support passing a payload through a block on top
148
173
  # of a message.
149
174
  if defined?(ActiveSupport::Logger) && logger.is_a?(ActiveSupport::Logger)
150
- logger.send(level) { "#{formatted_message(msg)} -- #{payload_block.call}" }
175
+ # The logger is fairly basic in terms of formatting. All inputs get converted
176
+ # as regular strings.
177
+ logger.send(level) { "#{formatted_message_as_string(msg)} -- #{payload_block.call}" }
151
178
  else
152
179
  logger.send(level, formatted_message(msg), &payload_block)
153
180
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cloudtasker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.rc5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arnaud Lachaume
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-03-11 00:00:00.000000000 Z
11
+ date: 2021-03-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -427,9 +427,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
427
427
  version: '0'
428
428
  required_rubygems_version: !ruby/object:Gem::Requirement
429
429
  requirements:
430
- - - ">="
430
+ - - ">"
431
431
  - !ruby/object:Gem::Version
432
- version: '0'
432
+ version: 1.3.1
433
433
  requirements: []
434
434
  rubygems_version: 3.0.0
435
435
  signing_key: