cloudtasker 0.15.rc2 → 0.15.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42cff7c50d99de1f66e83e04a3946c7cf370ce03004ee47dc7ee3dcbb46564c8
4
- data.tar.gz: 26997c5f357a41563a074e2d03ea2724c3824b46a4b034e45b9e5573d4024cbe
3
+ metadata.gz: 9ff26870d0e8805c1d799e77ca9e75904ed73f662fe19fa2296f79e276948b86
4
+ data.tar.gz: ab653c93ebda2b48aac08af8046324e7ee2ff14b368e9bbb656b1a7f79793749
5
5
  SHA512:
6
- metadata.gz: 0aa102da9cbc6ba0d108eb5d3be521efd954544bc7f4a47c11830401c61d2e590cbad7d7d540448dd4c41df7d11774c663c519a1b044518dc1a7c9f46c2c5906
7
- data.tar.gz: b415391333360f336f0553297206ce725df0d617a14f3dc0a8fe8c8fb81b7ead8b9fbe92f608ba75214af0469161b2abf1aab65c9b400900a7e5b047d6ed4a18
6
+ metadata.gz: 36ab5b779f9e7a56090983cfb82384c15a299bfd1300ce13fab1074bef6616c465269a766d1bc0b30a536336608d96359ee4cf26ff575590155afd3606ee4b3a
7
+ data.tar.gz: 98a7468f308d8ec6963b4909783a34e0257825c3a307610d05d7dbbd24e67030d880c3b5de273a589b327feee1def92396247c012c580db8eb532fb9628ee572
@@ -32,7 +32,7 @@ jobs:
32
32
  BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.appraisal }}.gemfile
33
33
  steps:
34
34
  - uses: actions/checkout@v2
35
- - uses: zhulik/redis-action@1.1.0
35
+ - uses: shogo82148/actions-setup-redis@v1
36
36
  - uses: ruby/setup-ruby@v1
37
37
  with:
38
38
  ruby-version: ${{ matrix.ruby }}
data/.rubocop.yml CHANGED
@@ -12,7 +12,7 @@ Metrics/ClassLength:
12
12
  Max: 300
13
13
 
14
14
  Metrics/ModuleLength:
15
- Max: 150
15
+ Max: 300
16
16
 
17
17
  Metrics/AbcSize:
18
18
  Max: 30
@@ -68,10 +68,14 @@ RSpec/MessageSpies:
68
68
  Enabled: false
69
69
 
70
70
  RSpec/MultipleExpectations:
71
+ Max: 5
71
72
  Exclude:
72
73
  - "examples/**/*"
73
74
  - "spec/integration/**/*"
74
75
 
76
+ RSpec/ExampleLength:
77
+ Max: 10
78
+
75
79
  RSpec/AnyInstance:
76
80
  Enabled: false
77
81
 
data/CHANGELOG.md CHANGED
@@ -1,13 +1,26 @@
1
1
  # Changelog
2
2
 
3
- ## [v0.15.rc2](https://github.com/keypup-io/cloudtasker/tree/v0.15.rc2) (2025-11-13)
3
+ ## [v0.15.0](https://github.com/keypup-io/cloudtasker/tree/v0.15.0) (2026-06-26)
4
4
 
5
- [Full Changelog](https://github.com/keypup-io/cloudtasker/compare/v0.14.0...v0.15.rc2)
5
+ [Full Changelog](https://github.com/keypup-io/cloudtasker/compare/v0.14.0...v0.15.0)
6
6
 
7
7
  **Improvements:**
8
+ - Local Server: allow retry delay to be configured via the env var `LOCAL_SERVER_RETRY_DELAY`
9
+ - Local Server: fix a syntax error on trap handling, which was re-raising an unnecessary exception.
10
+ - Logging: add job `duration` on error logs
11
+ - Logging: log error message as `reason` on `RetryWorkerError`
8
12
  - Queues: support `propagate_queue: true` option on `cloudtasker_options` to make workers enqueued inside a job use the runtime queue instead of the default (class-configured or `default`) queue.
13
+ - Testing: add specific `raise_errors!` mode to raise job errors during testing, without having to specifically use `inline_mode`. Using `inline_mode` will still raise errors.
9
14
  - Unique Jobs: add `until_completed` strategy to lock jobs until they are completed or have exhausted all retries (thanks @jam-packed).
15
+ - Workers: capture `job_attempts` on workers, which is captured from `X-CloudTasks-TaskRetryCount`
16
+ - Workers: increase the allowed max task size to 1049KB since Google Tasks supports increased payloads
10
17
  - Workers: provide `perform_now` method to perform job inline and align with other job frameworks
18
+ - Workers: support a global configuration option to control whether task payloads are base64-encoded. See `base64_encode_body`
19
+
20
+ **Fixed bugs:**
21
+ - Batch Jobs: prevent rollback of completion status if a completed job is replayed. This may happen if Cloud Tasks times out before the job completes.
22
+ - Batch Jobs: prevent rollback of job statuses due to multiple children completing concurrently and updating the parent job status (race condition in batch completion callback).
23
+ - Unique Jobs: avoid long-lock on OOM/error while scheduling jobs. An initial short-lived lock is now acquired before scheduling the job, which is then turned into a long-lived lock after the job has been scheduled. This prevents jobs from being enqueued for a fair amount of time after a scheduling crash.
11
24
 
12
25
  **Maintenance:**
13
26
  - Supported rubies: drop support for ruby `2.7`. Cloudtasker now requires ruby `3.0` and above.
data/README.md CHANGED
@@ -122,7 +122,7 @@ Open a Rails console and enqueue some jobs
122
122
  DummyWorker.perform_in(60, 'foo')
123
123
 
124
124
  # Process job immediately, inline
125
- # Supported since: v0.15.rc2
125
+ # Supported since: v0.15.0
126
126
  DummyWorker.perform_now('foo')
127
127
  ```
128
128
 
@@ -446,6 +446,18 @@ Cloudtasker.configure do |config|
446
446
  # Default: true
447
447
  #
448
448
  # config.local_server_ssl_verify = true
449
+
450
+ #
451
+ # Enable/disable the base64 encoding of task payloads when sent to Google Cloud Tasks.
452
+ #
453
+ # Base64-encoding is required for job payloads containing special characters.
454
+ # The downside is that the Google Cloud Task UI will therefore obfuscate the payload and force users to manually decode the payload to see the actual content.
455
+ #
456
+ # Supported since: v0.15.0
457
+ #
458
+ # Default: true
459
+ #
460
+ # config.base64_encode_body = true
449
461
  end
450
462
  ```
451
463
 
@@ -481,7 +493,7 @@ MyWorker.schedule(args: [arg1, arg2], time_in: 5 * 60, queue: 'critical')
481
493
 
482
494
  # Perform worker immediately, inline. This will not send the job to
483
495
  # the processing queue. Middlewares such as Unique Job, Batch Jobs will still be invoked.
484
- # Supported since: v0.15.rc2
496
+ # Supported since: v0.15.0
485
497
  MyWorker.perform_now(arg1, arg2)
486
498
  ```
487
499
 
@@ -555,7 +567,7 @@ CriticalWorker.schedule(args: [1], queue: :important)
555
567
  ```
556
568
 
557
569
  ### Propagating the queue in child workers
558
- **Supported since:** `v0.15.rc2`
570
+ **Supported since:** `v0.15.0`
559
571
 
560
572
  You can specify `propagate_queue: true` via the `cloudtasker_options` to make workers enqueued inside a job use the runtime queue instead of the default (class-configured or `default`) queue:
561
573
 
@@ -63,7 +63,9 @@ module Cloudtasker
63
63
  #
64
64
  def payload
65
65
  # Return content parsed as JSON and add job retries count
66
- @payload ||= JSON.parse(json_payload).merge(job_retries: job_retries, task_id: task_id)
66
+ @payload ||= JSON.parse(json_payload).merge(
67
+ job_retries: job_retries, job_attempts: job_attempts, task_id: task_id
68
+ )
67
69
  end
68
70
 
69
71
  #
@@ -75,6 +77,16 @@ module Cloudtasker
75
77
  request.headers[Cloudtasker::Config::RETRY_HEADER].to_i
76
78
  end
77
79
 
80
+ #
81
+ # Extract the number of times this task was attempted at runtime.
82
+ # This includes all attempts (including 50x errors).
83
+ #
84
+ # @return [Integer] The number of attempts.
85
+ #
86
+ def job_attempts
87
+ request.headers[Cloudtasker::Config::ATTEMPT_HEADER].to_i
88
+ end
89
+
78
90
  #
79
91
  # Return the Google Cloud Task ID from headers.
80
92
  #
data/docs/UNIQUE_JOBS.md CHANGED
@@ -70,7 +70,7 @@ For each lock strategy the table specifies the lock period (start/end) and which
70
70
  | `until_executing` | The job is scheduled | The job starts processing | `reject` (default) or `raise` |
71
71
  | `while_executing` | The job starts processing | The job ends processing | `reject` (default), `reschedule` or `raise` |
72
72
  | `until_executed` | The job is scheduled | The job ends processing | `reject` (default) or `raise` |
73
- | `until_completed` | The job is scheduled | The job completes successfully or a `DeadWorkerError` is raised. Supported since `v0.15.rc1`. | `reject` (default) or `raise` |
73
+ | `until_completed` | The job is scheduled | The job completes successfully or a `DeadWorkerError` is raised. Supported since `v0.15.0`. | `reject` (default) or `raise` |
74
74
 
75
75
  ## Available conflict strategies
76
76
 
@@ -119,12 +119,16 @@ module Cloudtasker
119
119
  # Format dispatch_deadline to Google::Protobuf::Duration
120
120
  payload[:dispatch_deadline] = format_protobuf_duration(payload[:dispatch_deadline])
121
121
 
122
- # Encode job content to support UTF-8. Google Cloud Task
123
- # expect content to be ASCII-8BIT compatible (binary)
122
+ # Setup headers
124
123
  payload[:http_request][:headers] ||= {}
125
124
  payload[:http_request][:headers][Cloudtasker::Config::CONTENT_TYPE_HEADER] = 'text/json'
126
- payload[:http_request][:headers][Cloudtasker::Config::ENCODING_HEADER] = 'Base64'
127
- payload[:http_request][:body] = Base64.encode64(payload[:http_request][:body])
125
+
126
+ # Conditionally encode job content to support UTF-8.
127
+ # Google Cloud Task expect content to be ASCII-8BIT compatible (binary)
128
+ if config.base64_encode_body
129
+ payload[:http_request][:headers][Cloudtasker::Config::ENCODING_HEADER] = 'Base64'
130
+ payload[:http_request][:body] = Base64.encode64(payload[:http_request][:body])
131
+ end
128
132
 
129
133
  payload.compact
130
134
  end
@@ -121,12 +121,16 @@ module Cloudtasker
121
121
  # Format dispatch_deadline to Google::Protobuf::Duration
122
122
  payload[:dispatch_deadline] = format_protobuf_duration(payload[:dispatch_deadline])
123
123
 
124
- # Encode job content to support UTF-8.
125
- # Google Cloud Task expect content to be ASCII-8BIT compatible (binary)
124
+ # Setup headers
126
125
  payload[:http_request][:headers] ||= {}
127
126
  payload[:http_request][:headers][Cloudtasker::Config::CONTENT_TYPE_HEADER] = 'text/json'
128
- payload[:http_request][:headers][Cloudtasker::Config::ENCODING_HEADER] = 'Base64'
129
- payload[:http_request][:body] = Base64.encode64(payload[:http_request][:body])
127
+
128
+ # Conditionally encode job content to support UTF-8.
129
+ # Google Cloud Task expect content to be ASCII-8BIT compatible (binary)
130
+ if config.base64_encode_body
131
+ payload[:http_request][:headers][Cloudtasker::Config::ENCODING_HEADER] = 'Base64'
132
+ payload[:http_request][:body] = Base64.encode64(payload[:http_request][:body])
133
+ end
130
134
 
131
135
  payload.compact
132
136
  end
@@ -17,6 +17,15 @@ module Cloudtasker
17
17
  defined?(Cloudtasker::Testing) && Cloudtasker::Testing.inline?
18
18
  end
19
19
 
20
+ #
21
+ # Return true if errors must be raised immediately
22
+ #
23
+ # @return [Boolean] True if raise error mode is enabled.
24
+ #
25
+ def self.raise_errors?
26
+ defined?(Cloudtasker::Testing) && Cloudtasker::Testing.raise_errors?
27
+ end
28
+
20
29
  #
21
30
  # Return the task queue. A worker class name
22
31
  #
@@ -172,10 +181,10 @@ module Cloudtasker
172
181
  resp
173
182
  rescue DeadWorkerError => e
174
183
  self.class.delete(id)
175
- raise(e) if self.class.inline_mode?
184
+ raise(e) if self.class.raise_errors?
176
185
  rescue StandardError => e
177
186
  self.job_retries += 1
178
- raise(e) if self.class.inline_mode?
187
+ raise(e) if self.class.raise_errors?
179
188
  end
180
189
 
181
190
  #
@@ -35,6 +35,10 @@ module Cloudtasker
35
35
  # to be acquired.
36
36
  BATCH_MAX_LOCK_WAIT = 60
37
37
 
38
+ # TTL for the completion flag that prevents concurrent children from
39
+ # triggering multiple on_complete calls.
40
+ BATCH_COMPLETION_TTL = 100
41
+
38
42
  #
39
43
  # Return the cloudtasker redis client
40
44
  #
@@ -193,6 +197,15 @@ module Cloudtasker
193
197
  "#{batch_state_gid}/state_count/#{state}"
194
198
  end
195
199
 
200
+ #
201
+ # Return the key used to track batch completion.
202
+ #
203
+ # @return [String] The batch completion key.
204
+ #
205
+ def batch_completion_gid
206
+ "#{batch_state_gid}/completed"
207
+ end
208
+
196
209
  #
197
210
  # Return the number of jobs in a given state
198
211
  #
@@ -314,21 +327,34 @@ module Cloudtasker
314
327
  #
315
328
  # Update the batch state.
316
329
  #
317
- # @param [String] job_id The batch id.
330
+ # @param [String] batch_id The batch id.
318
331
  # @param [String] status The status of the sub-batch.
332
+ # @param [Boolean] force Force update the status even if the registered status is a completion status.
319
333
  #
320
- def update_state(batch_id, status)
334
+ def update_state(batch_id, status, force: false)
321
335
  migrate_batch_state_to_redis_hash
322
336
 
323
- # Get current status
324
- current_status = redis.hget(batch_state_gid, batch_id)
325
- return if current_status == status.to_s
337
+ # Get stored status and abort if no changes
338
+ stored_status = redis.hget(batch_state_gid, batch_id)
339
+ return if stored_status == status.to_s
340
+
341
+ # Abort if the job has already been flagged as completed
342
+ #
343
+ # A job may be duplicated and run concurrently if Cloud Task times out
344
+ # before the job completes.
345
+ #
346
+ # In this case, the original job keeps running in the background while Cloud Task triggers a retry.
347
+ # This retry runs in parallel of the hung job. The retry may complete before the hung job.
348
+ # The hung job may eventually raise an error (e.g. timeout error) after the retried job has completed,
349
+ # which would leave the job status as "errored" instead of "completed in the batch state without
350
+ # the failsafe below.
351
+ return if COMPLETION_STATUSES.include?(stored_status) && !force
326
352
 
327
353
  # Update the batch state batch_id entry with the new status
328
354
  # and update counters
329
355
  redis.multi do |m|
330
356
  m.hset(batch_state_gid, batch_id, status)
331
- m.decr(batch_state_count_gid(current_status))
357
+ m.decr(batch_state_count_gid(stored_status))
332
358
  m.incr(batch_state_count_gid(status))
333
359
  end
334
360
  end
@@ -407,8 +433,22 @@ module Cloudtasker
407
433
  run_worker_callback(:on_child_dead, child_batch.worker)
408
434
  end
409
435
 
410
- # Notify the parent batch that we are done with this batch
411
- on_complete if status != :errored && complete?
436
+ return if status == :errored
437
+ return unless complete?
438
+
439
+ # Notify the parent batch that we are done with this batch. Use SETNX to ensure
440
+ # only the first concurrent child to complete triggers on_complete.
441
+ return unless redis.set(batch_completion_gid, true, nx: true, ex: BATCH_COMPLETION_TTL)
442
+
443
+ begin
444
+ on_complete
445
+ rescue StandardError
446
+ # Clear key on error so completion can be reattempted
447
+ redis.del(batch_completion_gid)
448
+
449
+ # Re-raise
450
+ raise
451
+ end
412
452
  end
413
453
 
414
454
  #
@@ -439,6 +479,7 @@ module Cloudtasker
439
479
  redis.multi do |m|
440
480
  m.del(batch_gid)
441
481
  m.del(batch_state_gid)
482
+ m.del(batch_completion_gid)
442
483
  BATCH_STATUSES.each { |e| m.del(batch_state_count_gid(e)) }
443
484
  end
444
485
  end
@@ -82,7 +82,7 @@ module Cloudtasker
82
82
  logger.info "[Cloudtasker/Server] Booted Rails #{::Rails.version} application in #{environment} environment"
83
83
  end
84
84
 
85
- # Get internal read/write pip
85
+ # Get internal read/write pipe
86
86
  self_read, self_write = IO.pipe
87
87
 
88
88
  # Setup signals to trap
@@ -104,7 +104,7 @@ module Cloudtasker
104
104
  local_server.start(opts)
105
105
 
106
106
  while (readable_io = read_pipe.wait_readable)
107
- signal = readable_io.first[0].gets.strip
107
+ signal = readable_io.first.chomp
108
108
  handle_signal(signal)
109
109
  end
110
110
  rescue Interrupt
@@ -82,12 +82,24 @@ module Cloudtasker
82
82
  # @return [Cloudtasker::CloudTask] The created task.
83
83
  #
84
84
  def self.create(payload)
85
- raise MaxTaskSizeExceededError if payload.to_json.bytesize > Config::MAX_TASK_SIZE
85
+ raise MaxTaskSizeExceededError if payload_size(payload) > Config::MAX_TASK_SIZE
86
86
 
87
87
  resp = backend.create(payload)&.to_h
88
88
  resp ? new(**resp) : nil
89
89
  end
90
90
 
91
+ #
92
+ # Calculate the size of a task payload.
93
+ # The size of the task will be inflated if Base64 encoding is used.
94
+ #
95
+ # @param [Hash] payload The payload of the task
96
+ #
97
+ # @return [Integer] The size of of the payload, in bytes.
98
+ #
99
+ def self.payload_size(payload)
100
+ (Cloudtasker.config.base64_encode_body ? Base64.encode64(payload.to_json) : payload.to_json).bytesize
101
+ end
102
+
91
103
  #
92
104
  # Delete a cloud task by id.
93
105
  #
@@ -8,18 +8,22 @@ module Cloudtasker
8
8
  attr_accessor :redis, :store_payloads_in_redis, :gcp_queue_prefix
9
9
  attr_writer :secret, :gcp_location_id, :gcp_project_id,
10
10
  :processor_path, :logger, :mode, :max_retries,
11
- :dispatch_deadline, :on_error, :on_dead, :oidc, :local_server_ssl_verify
11
+ :dispatch_deadline, :on_error, :on_dead, :oidc, :local_server_ssl_verify,
12
+ :base64_encode_body
12
13
 
13
14
  # Max Cloud Task size in bytes
14
- MAX_TASK_SIZE = 100 * 1024 # 100 KB
15
+ # The GCP limit is 1MiB, which is 1049KB.
16
+ # The task formatting and headers add about 20KB.
17
+ MAX_TASK_SIZE = 1000 * 1000 # 1000 KB
15
18
 
16
19
  # Retry header in Cloud Task responses
17
20
  #
18
21
  # Definitions:
19
- # X-CloudTasks-TaskRetryCount: total number of retries (including 504 "instance unreachable")
20
- # X-CloudTasks-TaskExecutionCount: number of non-503 retries (= actual number of job failures)
22
+ # X-CloudTasks-TaskRetryCount: total number of retries (including 50x errors)
23
+ # X-CloudTasks-TaskExecutionCount: number of non-50x retries (= actual number of job failures)
21
24
  #
22
25
  RETRY_HEADER = 'X-Cloudtasks-Taskexecutioncount'
26
+ ATTEMPT_HEADER = 'X-CloudTasks-TaskRetryCount'
23
27
 
24
28
  # Cloud Task ID header
25
29
  TASK_ID_HEADER = 'X-CloudTasks-TaskName'
@@ -56,6 +60,9 @@ module Cloudtasker
56
60
  # Default on_error Proc
57
61
  DEFAULT_ON_ERROR = ->(error, worker) {}
58
62
 
63
+ # Default base64 encoding flag
64
+ DEFAULT_BASE64_ENCODE_BODY = true
65
+
59
66
  # Cache key prefix used to store workers in cache and retrieve
60
67
  # them later.
61
68
  WORKER_STORE_PREFIX = 'worker_store'
@@ -301,5 +308,15 @@ module Cloudtasker
301
308
  def local_server_ssl_verify
302
309
  @local_server_ssl_verify.nil? ? DEFAULT_LOCAL_SERVER_SSL_VERIFY_MODE : @local_server_ssl_verify
303
310
  end
311
+
312
+ #
313
+ # Return whether to base64 encode the task body when sending to Cloud Tasks.
314
+ # Encoding is enabled by default to support UTF-8 content.
315
+ #
316
+ # @return [Boolean] Whether to base64 encode the body.
317
+ #
318
+ def base64_encode_body
319
+ @base64_encode_body.nil? ? DEFAULT_BASE64_ENCODE_BODY : @base64_encode_body
320
+ end
304
321
  end
305
322
  end
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cloudtasker
4
+ # Error raised when a worker class cannot be instantiated.
4
5
  class InvalidWorkerError < StandardError
6
+ def initialize(worker_name = nil)
7
+ super(worker_name ? "Invalid worker: #{worker_name}" : 'Invalid worker')
8
+ end
5
9
  end
6
10
  end
@@ -5,7 +5,7 @@ module Cloudtasker
5
5
  # See: https://cloud.google.com/appengine/quotas#Task_Queue
6
6
  #
7
7
  class MaxTaskSizeExceededError < StandardError
8
- MSG = 'The size of Cloud Tasks must not exceed 100KB'
8
+ MSG = "The size of Cloud Tasks must not exceed #{Config::MAX_TASK_SIZE / 1000}KB".freeze
9
9
 
10
10
  def initialize(msg = MSG)
11
11
  super
@@ -29,6 +29,28 @@ module Cloudtasker
29
29
  end
30
30
  end
31
31
 
32
+ #
33
+ # Set the error mode, either permanently or
34
+ # temporarily (via block).
35
+ #
36
+ # @param [Symbol] mode The error mode.
37
+ #
38
+ # @return [Symbol] The error mode.
39
+ #
40
+ def switch_error_mode(mode)
41
+ if block_given?
42
+ current_mode = @error_mode
43
+ begin
44
+ @error_mode = mode
45
+ yield
46
+ ensure
47
+ @error_mode = current_mode
48
+ end
49
+ else
50
+ @error_mode = mode
51
+ end
52
+ end
53
+
32
54
  #
33
55
  # Set cloudtasker to real mode temporarily
34
56
  #
@@ -81,6 +103,35 @@ module Cloudtasker
81
103
  @test_mode == :inline
82
104
  end
83
105
 
106
+ #
107
+ # Temporarily raise errors in the same manner
108
+ # inline! does it.
109
+ #
110
+ # This is used when you want to manually drain the jobs
111
+ # but still want to surface errors at runtime, instead of
112
+ # using the retry mechanic.
113
+ #
114
+ def raise_errors!(&block)
115
+ switch_error_mode(:raise, &block)
116
+ end
117
+
118
+ #
119
+ # Temporarily silence errors. Job will follow the retry logic.
120
+ #
121
+ def silence_errors!(&block)
122
+ switch_error_mode(:silence, &block)
123
+ end
124
+
125
+ #
126
+ # Return true if jobs should raise errors immediately
127
+ # without relying on retries.
128
+ #
129
+ # @return [Boolean] True if jobs are run inline.
130
+ #
131
+ def raise_errors?
132
+ @test_mode == :inline || @error_mode == :raise
133
+ end
134
+
84
135
  #
85
136
  # Return true if tasks should be managed in memory.
86
137
  #
@@ -10,6 +10,12 @@ module Cloudtasker
10
10
  # The default lock strategy to use. Defaults to "no lock".
11
11
  DEFAULT_LOCK = UniqueJob::Lock::NoOp
12
12
 
13
+ # Warning message when final lock cannot be acquired after scheduling
14
+ LOCK_FINALIZATION_WARNING = 'A provisional lock was acquired before enqueuing the job but the ' \
15
+ 'lock could not be finalized after enqueuing the job. This means that ' \
16
+ 'it took longer than lock_provisional_ttl to enqueue the job. See ' \
17
+ 'Worker#lock_provisional_ttl option.'
18
+
13
19
  #
14
20
  # Build a new instance of the class.
15
21
  #
@@ -18,7 +24,7 @@ module Cloudtasker
18
24
  #
19
25
  def initialize(worker, opts = {})
20
26
  @worker = worker
21
- @call_opts = opts
27
+ @call_opts = opts.to_h
22
28
  end
23
29
 
24
30
  #
@@ -63,8 +69,26 @@ module Cloudtasker
63
69
  scheduled_at = [call_opts[:time_at].to_i, now].compact.max
64
70
  lock_duration = (options[:lock_ttl] || Cloudtasker::UniqueJob.lock_ttl).to_i
65
71
 
66
- # Return TTL
67
- scheduled_at + lock_duration - now
72
+ # Return the TTL, which is the configured lock_duration at minima
73
+ [lock_duration, scheduled_at + lock_duration - now].max
74
+ end
75
+
76
+ #
77
+ # A provisional lock uses a very short duration and aims
78
+ # at covering the time it takes for the job to be enqueued through
79
+ # the client middleware chain.
80
+ #
81
+ # If the application crashes during this
82
+ # time (e.g. OOM), at least the job won't be locked for an extended period
83
+ # of time (which may span across a parent job retry, for instance)
84
+ #
85
+ # This TTL can be configured via the `lock_provisional_ttl` option on
86
+ # the job itself.
87
+ #
88
+ # @return [Integer] The TTL in seconds
89
+ #
90
+ def lock_provisional_ttl
91
+ (options[:lock_provisional_ttl] || Cloudtasker::UniqueJob.lock_provisional_ttl).to_i
68
92
  end
69
93
 
70
94
  #
@@ -93,6 +117,29 @@ module Cloudtasker
93
117
  worker.try(:unique_args, worker.job_args) || worker.job_args
94
118
  end
95
119
 
120
+ #
121
+ # The base unique scope generated from lock options
122
+ #
123
+ # @return [Hash] A scope hash
124
+ #
125
+ def base_unique_scope
126
+ if options[:lock_per_batch] && defined?(Cloudtasker::Batch::Job)
127
+ key = Cloudtasker::Batch::Job.key(:parent_id).to_sym
128
+ worker.job_meta.to_h.slice(key)
129
+ else
130
+ {}
131
+ end
132
+ end
133
+
134
+ #
135
+ # Return a scope to be included in the digest hash
136
+ #
137
+ # @return [Hash] A scope hash
138
+ #
139
+ def unique_scope
140
+ base_unique_scope.to_h.merge(worker.try(:unique_scope).to_h)
141
+ end
142
+
96
143
  #
97
144
  # Return a unique description of the job in hash format.
98
145
  #
@@ -101,8 +148,9 @@ module Cloudtasker
101
148
  def digest_hash
102
149
  @digest_hash ||= {
103
150
  class: worker.class.to_s,
104
- unique_args: unique_args
105
- }
151
+ unique_args: unique_args,
152
+ unique_scope: unique_scope.presence
153
+ }.compact
106
154
  end
107
155
 
108
156
  #
@@ -156,6 +204,49 @@ module Cloudtasker
156
204
  raise(LockError) unless lock_acquired || lock_already_acquired
157
205
  end
158
206
 
207
+ #
208
+ # Acquire a provisional lock, yield, then set a final lock.
209
+ #
210
+ # This method is designed for scheduling operations where you need to:
211
+ # 1. Acquire a provisional lock to prevent concurrent scheduling
212
+ # 2. Perform the scheduling operation (yield)
213
+ # 3. Set a final lock with proper TTL after scheduling succeeds
214
+ #
215
+ # Raises a `Cloudtasker::UniqueJob::LockError` if the provisional lock
216
+ # cannot be acquired.
217
+ #
218
+ # @return [Any] The return value of the block
219
+ #
220
+ def lock_for_scheduling!
221
+ # Step 1: Acquire provisional lock
222
+ # Check if the lock is already acquired from a previous run
223
+ acquired = redis.get(unique_gid) == id
224
+
225
+ # Set the lock exclusively, if not acquired already.
226
+ # Refresh the duration otherwise.
227
+ lock_acquired = redis.set(unique_gid, id, nx: !acquired, ex: lock_provisional_ttl)
228
+ raise(LockError) unless lock_acquired
229
+
230
+ # Step 2: Yield to perform scheduling operation
231
+ result = yield
232
+
233
+ # Step 3: Set final lock
234
+ # Check if the lock is still held by this job
235
+ acquired = redis.get(unique_gid) == id
236
+
237
+ # Set the lock with final duration
238
+ # If already acquired, refresh with final TTL
239
+ # If not acquired (expired or taken), try to acquire exclusively
240
+ final_lock_acquired = redis.set(unique_gid, id, nx: !acquired, ex: lock_ttl)
241
+
242
+ # Log a warning if final lock could not be acquired
243
+ # The job has already been enqueued at this point, so raising an error is useless
244
+ worker.logger.warn(LOCK_FINALIZATION_WARNING) unless final_lock_acquired
245
+
246
+ # Return the result of the block
247
+ result
248
+ end
249
+
159
250
  #
160
251
  # Delete the job lock.
161
252
  #
@@ -12,10 +12,13 @@ module Cloudtasker
12
12
  # if the lock could not be acquired.
13
13
  #
14
14
  def schedule(&block)
15
- job.lock!
16
- yield
15
+ job.lock_for_scheduling!(&block)
17
16
  rescue LockError
18
17
  conflict_instance.on_schedule(&block)
18
+ rescue StandardError
19
+ # Unlock the job if any error arises during scheduling
20
+ job.unlock!
21
+ raise
19
22
  end
20
23
 
21
24
  #
@@ -11,10 +11,13 @@ module Cloudtasker
11
11
  # if the lock could not be acquired.
12
12
  #
13
13
  def schedule(&block)
14
- job.lock!
15
- yield
14
+ job.lock_for_scheduling!(&block)
16
15
  rescue LockError
17
16
  conflict_instance.on_schedule(&block)
17
+ rescue StandardError
18
+ # Unlock the job if any error arises during scheduling
19
+ job.unlock!
20
+ raise
18
21
  end
19
22
 
20
23
  #
@@ -11,10 +11,13 @@ module Cloudtasker
11
11
  # if the lock could not be acquired.
12
12
  #
13
13
  def schedule(&block)
14
- job.lock!
15
- yield
14
+ job.lock_for_scheduling!(&block)
16
15
  rescue LockError
17
16
  conflict_instance.on_schedule(&block)
17
+ rescue StandardError
18
+ # Unlock the job if any error arises during scheduling
19
+ job.unlock!
20
+ raise
18
21
  end
19
22
 
20
23
  #
@@ -3,11 +3,10 @@
3
3
  module Cloudtasker
4
4
  module UniqueJob
5
5
  module Middleware
6
- # TODO: kwargs to job otherwise it won't get the time_at
7
6
  # Client middleware, invoked when jobs are scheduled
8
7
  class Client
9
- def call(worker, _opts = {}, &block)
10
- Job.new(worker).lock_instance.schedule(&block)
8
+ def call(worker, opts = {}, &block)
9
+ Job.new(worker, opts).lock_instance.schedule(&block)
11
10
  end
12
11
  end
13
12
  end
@@ -11,8 +11,12 @@ module Cloudtasker
11
11
  # after schedule time.
12
12
  DEFAULT_LOCK_TTL = 10 * 60 # 10 minutes
13
13
 
14
+ # The maximum duration of the provisional lock while
15
+ # enqueuing a job.
16
+ DEFAULT_LOCK_PROVISIONAL_TTL = 3
17
+
14
18
  class << self
15
- attr_writer :lock_ttl
19
+ attr_writer :lock_ttl, :lock_provisional_ttl
16
20
 
17
21
  # Configure the middleware
18
22
  def configure
@@ -27,6 +31,15 @@ module Cloudtasker
27
31
  def lock_ttl
28
32
  @lock_ttl || DEFAULT_LOCK_TTL
29
33
  end
34
+
35
+ #
36
+ # Return the provisional TTL for locks
37
+ #
38
+ # @return [Integer] The lock TTL.
39
+ #
40
+ def lock_provisional_ttl
41
+ @lock_provisional_ttl || DEFAULT_LOCK_PROVISIONAL_TTL
42
+ end
30
43
  end
31
44
  end
32
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cloudtasker
4
- VERSION = '0.15.rc2'
4
+ VERSION = '0.15.0'
5
5
  end
@@ -8,7 +8,7 @@ module Cloudtasker
8
8
  base.extend(ClassMethods)
9
9
  base.attr_writer :job_queue
10
10
  base.attr_accessor :job_args, :job_id, :job_meta, :job_reenqueued, :job_retries,
11
- :perform_started_at, :perform_ended_at, :task_id
11
+ :job_attempts, :perform_started_at, :perform_ended_at, :task_id
12
12
  end
13
13
 
14
14
  #
@@ -47,7 +47,10 @@ module Cloudtasker
47
47
  return nil unless worker_klass.include?(self)
48
48
 
49
49
  # Return instantiated worker
50
- worker_klass.new(**payload.slice(:job_queue, :job_args, :job_id, :job_meta, :job_retries, :task_id))
50
+ worker_klass.new(**payload.slice(
51
+ :job_queue, :job_args, :job_id, :job_meta,
52
+ :job_retries, :job_attempts, :task_id
53
+ ))
51
54
  rescue NameError
52
55
  nil
53
56
  end
@@ -141,7 +144,9 @@ module Cloudtasker
141
144
  # @return [Any] The result of the worker perform method.
142
145
  #
143
146
  def perform_now(*args)
144
- new(job_args: args).execute
147
+ # Serialize/deserialize arguments to mimic job enqueueing and produce a similar context
148
+ job_args = JSON.parse(args.to_json)
149
+ new(job_args: job_args).execute
145
150
  end
146
151
 
147
152
  #
@@ -174,11 +179,13 @@ module Cloudtasker
174
179
  # @param [Array<any>] job_args The list of perform args.
175
180
  # @param [String] job_id A unique ID identifying this job.
176
181
  #
177
- def initialize(job_queue: nil, job_args: nil, job_id: nil, job_meta: {}, job_retries: 0, task_id: nil)
182
+ def initialize(job_queue: nil, job_args: nil, job_id: nil, job_meta: {}, job_retries: 0, job_attempts: 0,
183
+ task_id: nil)
178
184
  @job_args = job_args || []
179
185
  @job_id = job_id || SecureRandom.uuid
180
186
  @job_meta = MetaStore.new(job_meta)
181
187
  @job_retries = job_retries || 0
188
+ @job_attempts = job_attempts || 0
182
189
  @job_queue = job_queue
183
190
  @task_id = task_id
184
191
  end
@@ -243,16 +250,18 @@ module Cloudtasker
243
250
  resp = execute_middleware_chain
244
251
 
245
252
  # Log job completion and return result
246
- logger.info("Job done after #{job_duration}s") { { duration: job_duration * 1000 } }
253
+ logger.info("Job done after #{job_duration}s") { { duration: job_duration_ms } }
247
254
  resp
248
255
  rescue DeadWorkerError => e
249
- logger.info("Job dead after #{job_duration}s and #{job_retries} retries") { { duration: job_duration * 1000 } }
256
+ logger.info("Job dead after #{job_duration}s and #{job_retries} retries") { { duration: job_duration_ms } }
250
257
  raise(e)
251
258
  rescue RetryWorkerError => e
252
- logger.info("Job done after #{job_duration}s (retry requested)") { { duration: job_duration * 1000 } }
259
+ logger.info("Job done after #{job_duration}s (retry requested)") do
260
+ { duration: job_duration_ms, reason: e.message }
261
+ end
253
262
  raise(e)
254
263
  rescue StandardError => e
255
- logger.info("Job failed after #{job_duration}s") { { duration: job_duration * 1000 } }
264
+ logger.info("Job failed after #{job_duration}s") { { duration: job_duration_ms } }
256
265
  raise(e)
257
266
  end
258
267
 
@@ -327,6 +336,7 @@ module Cloudtasker
327
336
  job_args: job_args,
328
337
  job_meta: job_meta.to_h,
329
338
  job_retries: job_retries,
339
+ job_attempts: job_attempts,
330
340
  job_queue: job_queue,
331
341
  task_id: task_id
332
342
  }
@@ -418,6 +428,15 @@ module Cloudtasker
418
428
  @job_duration ||= (perform_ended_at - perform_started_at).ceil(3)
419
429
  end
420
430
 
431
+ #
432
+ # Return the job_duration in milliseconds
433
+ #
434
+ # @return [Float] The time taken in milliseconds as a floating point number.
435
+ #
436
+ def job_duration_ms
437
+ job_duration * 1000
438
+ end
439
+
421
440
  #
422
441
  # Run worker callback.
423
442
  #
@@ -60,8 +60,8 @@ module Cloudtasker
60
60
  # Worker will be nil on InvalidWorkerError - in that case we use generic logging
61
61
  logger = worker&.logger || Cloudtasker.logger
62
62
 
63
- # Log error
64
- logger.error(error)
63
+ # Log error with duration
64
+ logger.error(error) { { duration: worker&.job_duration_ms }.compact }
65
65
  end
66
66
 
67
67
  #
@@ -92,7 +92,7 @@ module Cloudtasker
92
92
  args_payload_key = extracted_payload[:args_payload_key]
93
93
 
94
94
  # Build worker
95
- worker = Cloudtasker::Worker.from_hash(payload) || raise(InvalidWorkerError)
95
+ worker = Cloudtasker::Worker.from_hash(payload) || raise(InvalidWorkerError, payload[:worker])
96
96
 
97
97
  # Yied worker
98
98
  resp = yield(worker)
@@ -178,7 +178,7 @@ module Cloudtasker
178
178
  #
179
179
  def store_payload_in_redis?
180
180
  Cloudtasker.config.redis_payload_storage_threshold &&
181
- worker.job_args.to_json.bytesize > (Cloudtasker.config.redis_payload_storage_threshold * 1024)
181
+ worker.job_args.to_json.bytesize > (Cloudtasker.config.redis_payload_storage_threshold * 1000)
182
182
  end
183
183
 
184
184
  #
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.15.rc2
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arnaud Lachaume
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-11-13 00:00:00.000000000 Z
11
+ date: 2026-06-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -237,7 +237,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
237
237
  - !ruby/object:Gem::Version
238
238
  version: '0'
239
239
  requirements: []
240
- rubygems_version: 3.5.4
240
+ rubygems_version: 3.5.22
241
241
  signing_key:
242
242
  specification_version: 4
243
243
  summary: Background jobs for Ruby using Google Cloud Tasks (beta)