cloudtasker 0.11.0 → 0.12.rc5

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.
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: