cloudtasker 0.12.rc2 → 0.12.rc7

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: 11c4d8d88b554792e888929324e79521426f5484973253a44c50a97849a5842c
4
- data.tar.gz: 1f10788d0ced509c82eea06ddfbd0b1d47a5731507c8bac5c597f41cc13b2d98
3
+ metadata.gz: 96524dc4a6825a3760a462277f7b0a710d779521e759bce44374ea044dbd649d
4
+ data.tar.gz: 908f1497b7ad316549f4f347c363b183181f0e0a17a92bda3f50ac9c34e2247c
5
5
  SHA512:
6
- metadata.gz: abe6437f179edd589afb88ed970c7870377c836446ba6b4169ffd60565be15fcf92557dd71965795579f58ba19af63a9225ad55cf8c97bb8ab5612e31d5c8bdf
7
- data.tar.gz: 23d17b1fade5f62594250b239b07e603bc8d8f0862465dd28ec3efc6b260cb86774e88d3b9aead4704eff1d071a3dda2f35d9d68302c6dcef56a1d8b707caffe
6
+ metadata.gz: e18579d27321e09f1e997ad3fdd3e3e0d1d0c344cd5fc553b64f665937fbe510cd11957381eee1fee5c100e2f14d8390434f26c60307fec4ecf4a681b15885d8
7
+ data.tar.gz: 3b688838f1a518f2f5c7b914a590f035d8a828c2c2b67838173a1712b8765e2ce34cddb8ddf1214aaefc26ce8ec43b7cc1dbe4eb450ccb24feb9e06bcd19bac7
data/.rubocop.yml CHANGED
@@ -6,7 +6,7 @@ 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
data/CHANGELOG.md CHANGED
@@ -1,15 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## Latest RC [v0.12.rc1](https://github.com/keypup-io/cloudtasker/tree/v0.12.rc1) (2021-03-11)
3
+ ## Latest RC [v0.12.rc7](https://github.com/keypup-io/cloudtasker/tree/v0.12.rc7) (2021-03-31)
4
4
 
5
- [Full Changelog](https://github.com/keypup-io/cloudtasker/compare/v0.11.0...v0.12.rc1)
5
+ [Full Changelog](https://github.com/keypup-io/cloudtasker/compare/v0.11.0...v0.12.rc7)
6
6
 
7
7
  **Improvements:**
8
8
  - ActiveJob: do not double log errors (ActiveJob has its own error logging)
9
+ - Cron jobs: Use Redis Sets instead of key pattern matching for resource listing
9
10
  - Error logging: Use worker logger so as to include context (job args etc.)
10
11
  - Error logging: Do not log exception and stack trace separately, combine them instead.
11
12
  - 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 state: use native Redis hashes to store batch state instead of a serialized hash in a string key
14
+ - 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.
15
+ - Local server: Use Redis Sets instead of key pattern matching for resource listing
16
+ - Worker: raise DeadWorkerError instead of MissingWorkerArgumentsError when arguments are missing. This is more consistent with what middlewares expect.
13
17
 
14
18
  **Fixed bugs:**
15
19
  - Retries: Enforce job retry limit on job processing. There was an edge case where jobs could be retried indefinitely on batch callback errors.
data/README.md CHANGED
@@ -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,7 +19,7 @@ module Cloudtasker
19
19
  # Process payload
20
20
  WorkerHandler.execute_from_payload!(payload)
21
21
  head :no_content
22
- rescue DeadWorkerError, MissingWorkerArgumentsError
22
+ rescue DeadWorkerError
23
23
  # 205: job will NOT be retried
24
24
  head :reset_content
25
25
  rescue InvalidWorkerError
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
  ```
@@ -178,6 +178,7 @@ module Cloudtasker
178
178
  schedule_time: (Time.now + interval).to_i,
179
179
  queue: queue
180
180
  )
181
+ redis.sadd(self.class.key, id)
181
182
  end
182
183
 
183
184
  #
@@ -17,6 +17,10 @@ module Cloudtasker
17
17
  # because the jobs will be either retried or dropped
18
18
  IGNORED_ERRORED_CALLBACKS = %i[on_child_error on_child_dead].freeze
19
19
 
20
+ # The maximum number of seconds to wait for a batch state lock
21
+ # to be acquired.
22
+ BATCH_MAX_LOCK_WAIT = 60
23
+
20
24
  #
21
25
  # Return the cloudtasker redis client
22
26
  #
@@ -176,7 +180,9 @@ module Cloudtasker
176
180
  # @return [Hash] The state of each child worker.
177
181
  #
178
182
  def batch_state
179
- redis.fetch(batch_state_gid)
183
+ migrate_batch_state_to_redis_hash
184
+
185
+ redis.hgetall(batch_state_gid)
180
186
  end
181
187
 
182
188
  #
@@ -208,6 +214,24 @@ module Cloudtasker
208
214
  )
209
215
  end
210
216
 
217
+ #
218
+ # This method migrates the batch state to be a Redis hash instead
219
+ # of a hash stored in a string key.
220
+ #
221
+ def migrate_batch_state_to_redis_hash
222
+ return unless redis.type(batch_state_gid) == 'string'
223
+
224
+ # Migrate batch state to Redis hash if it is still using a legacy string key
225
+ # We acquire a lock then check again
226
+ redis.with_lock(batch_state_gid, max_wait: BATCH_MAX_LOCK_WAIT) do
227
+ if redis.type(batch_state_gid) == 'string'
228
+ state = redis.fetch(batch_state_gid)
229
+ redis.del(batch_state_gid)
230
+ redis.hset(batch_state_gid, state) if state.any?
231
+ end
232
+ end
233
+ end
234
+
211
235
  #
212
236
  # Save the batch.
213
237
  #
@@ -218,8 +242,11 @@ module Cloudtasker
218
242
  # complete (success or failure).
219
243
  redis.write(batch_gid, worker.to_h)
220
244
 
245
+ # Stop there if no jobs to save
246
+ return if jobs.empty?
247
+
221
248
  # Save list of child workers
222
- redis.write(batch_state_gid, jobs.map { |e| [e.job_id, 'scheduled'] }.to_h)
249
+ redis.hset(batch_state_gid, jobs.map { |e| [e.job_id, 'scheduled'] }.to_h)
223
250
  end
224
251
 
225
252
  #
@@ -228,28 +255,27 @@ module Cloudtasker
228
255
  # @param [String] job_id The batch id.
229
256
  # @param [String] status The status of the sub-batch.
230
257
  #
231
- # @return [<Type>] <description>
232
- #
233
258
  def update_state(batch_id, status)
234
- redis.with_lock(batch_state_gid) do
235
- state = batch_state
236
- state[batch_id.to_sym] = status.to_s if state.key?(batch_id.to_sym)
237
- redis.write(batch_state_gid, state)
259
+ migrate_batch_state_to_redis_hash
260
+
261
+ # Update the batch state batch_id entry with the new status
262
+ redis.with_lock("#{batch_state_gid}/#{batch_id}", max_wait: BATCH_MAX_LOCK_WAIT) do
263
+ redis.hset(batch_state_gid, batch_id, status) if redis.hexists(batch_state_gid, batch_id)
238
264
  end
239
265
  end
240
266
 
241
267
  #
242
268
  # Return true if all the child workers have completed.
243
269
  #
244
- # @return [<Type>] <description>
270
+ # @return [Boolean] True if the batch is complete.
245
271
  #
246
272
  def complete?
247
- redis.with_lock(batch_state_gid) do
248
- state = redis.fetch(batch_state_gid)
249
- return true unless state
273
+ migrate_batch_state_to_redis_hash
250
274
 
275
+ # Check that all child jobs have completed
276
+ redis.with_lock(batch_state_gid, max_wait: BATCH_MAX_LOCK_WAIT) do
251
277
  # Check that all children are complete
252
- state.values.all? { |e| COMPLETION_STATUSES.include?(e) }
278
+ redis.hvals(batch_state_gid).all? { |e| COMPLETION_STATUSES.include?(e) }
253
279
  end
254
280
  end
255
281
 
@@ -331,11 +357,10 @@ module Cloudtasker
331
357
  # Remove all batch and sub-batch keys from Redis.
332
358
  #
333
359
  def cleanup
334
- # Capture batch state
335
- state = batch_state
360
+ migrate_batch_state_to_redis_hash
336
361
 
337
362
  # Delete child batches recursively
338
- state.to_h.keys.each { |id| self.class.find(id)&.cleanup }
363
+ redis.hkeys(batch_state_gid).each { |id| self.class.find(id)&.cleanup }
339
364
 
340
365
  # Delete batch redis entries
341
366
  redis.del(batch_gid)
@@ -347,13 +372,20 @@ module Cloudtasker
347
372
  #
348
373
  # @return [Cloudtasker::Batch::BatchProgress] The batch progress.
349
374
  #
350
- def progress
375
+ def progress(depth: 0)
376
+ depth = depth.to_i
377
+
351
378
  # Capture batch state
352
379
  state = batch_state
353
380
 
354
- # Sum batch progress of current batch and all sub-batches
381
+ # Return immediately if we do not need to go down the tree
382
+ return BatchProgress.new(state) if depth <= 0
383
+
384
+ # Sum batch progress of current batch and sub-batches up to the specified
385
+ # depth
355
386
  state.to_h.reduce(BatchProgress.new(state)) do |memo, (child_id, child_status)|
356
- memo + (self.class.find(child_id)&.progress || BatchProgress.new(child_id => child_status))
387
+ memo + (self.class.find(child_id)&.progress(depth: depth - 1) ||
388
+ BatchProgress.new(child_id => child_status))
357
389
  end
358
390
  end
359
391
 
@@ -395,7 +427,7 @@ module Cloudtasker
395
427
  # Perform job
396
428
  yield
397
429
 
398
- # Save batch (if child worker has been enqueued)
430
+ # Save batch (if child workers have been enqueued)
399
431
  setup
400
432
 
401
433
  # Complete batch
@@ -75,14 +75,18 @@ module Cloudtasker
75
75
  # end
76
76
  #
77
77
  # @param [String] cache_key The cache key to access.
78
+ # @param [Integer] max_wait The number of seconds after which the lock will be cleared anyway.
78
79
  #
79
- def with_lock(cache_key)
80
+ def with_lock(cache_key, max_wait: nil)
80
81
  return nil unless cache_key
81
82
 
83
+ # Set max wait
84
+ max_wait = (max_wait || LOCK_DURATION).to_i
85
+
82
86
  # Wait to acquire lock
83
87
  lock_key = [LOCK_KEY_PREFIX, cache_key].join('/')
84
88
  client.with do |conn|
85
- sleep(LOCK_WAIT_DURATION) until conn.set(lock_key, true, nx: true, ex: LOCK_DURATION)
89
+ sleep(LOCK_WAIT_DURATION) until conn.set(lock_key, true, nx: true, ex: max_wait)
86
90
  end
87
91
 
88
92
  # yield content
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cloudtasker
4
- VERSION = '0.12.rc2'
4
+ VERSION = '0.12.rc7'
5
5
  end
@@ -332,6 +332,22 @@ module Cloudtasker
332
332
  job_retries > job_max_retries
333
333
  end
334
334
 
335
+ #
336
+ # Return true if the job arguments are missing.
337
+ #
338
+ # This may happen if a job
339
+ # was successfully run but retried due to Cloud Task dispatch deadline
340
+ # exceeded. If the arguments were stored in Redis then they may have
341
+ # been flushed already after the successful completion.
342
+ #
343
+ # If job arguments are missing then the job will simply be declared dead.
344
+ #
345
+ # @return [Boolean] True if the arguments are missing.
346
+ #
347
+ def arguments_missing?
348
+ job_args.empty? && [0, -1].exclude?(method(:perform).arity)
349
+ end
350
+
335
351
  #
336
352
  # Return the time taken (in seconds) to perform the job. This duration
337
353
  # includes the middlewares and the actual perform method.
@@ -384,14 +400,9 @@ module Cloudtasker
384
400
  Cloudtasker.config.server_middleware.invoke(self) do
385
401
  # Immediately abort the job if it is already dead
386
402
  flag_as_dead if job_dead?
403
+ flag_as_dead(MissingWorkerArgumentsError.new('worker arguments are missing')) if arguments_missing?
387
404
 
388
405
  begin
389
- # Abort if arguments are missing. This may happen with redis arguments storage
390
- # if Cloud Tasks times out on a job but the job still succeeds
391
- if job_args.empty? && [0, -1].exclude?(method(:perform).arity)
392
- raise(MissingWorkerArgumentsError, 'worker arguments are missing')
393
- end
394
-
395
406
  # Perform the job
396
407
  perform(*job_args)
397
408
  rescue StandardError => e
@@ -107,7 +107,7 @@ module Cloudtasker
107
107
  redis.expire(args_payload_key, ARGS_PAYLOAD_CLEANUP_TTL) if args_payload_key && !worker.job_reenqueued
108
108
 
109
109
  resp
110
- rescue DeadWorkerError, MissingWorkerArgumentsError => e
110
+ rescue DeadWorkerError => e
111
111
  # Delete stored args payload if job is dead
112
112
  redis.expire(args_payload_key, ARGS_PAYLOAD_CLEANUP_TTL) if args_payload_key
113
113
  log_execution_error(worker, e)
@@ -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.12.rc2
4
+ version: 0.12.rc7
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-12 00:00:00.000000000 Z
11
+ date: 2021-03-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport