sidekiq-unique-jobs 6.0.0.rc6 → 6.0.0.rc7

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of sidekiq-unique-jobs might be problematic. Click here for more details.

Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +6 -7
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +1 -1
  4. data/.reek.yml +17 -48
  5. data/.rubocop.yml +3 -0
  6. data/.yardopts +7 -0
  7. data/CHANGELOG.md +2 -0
  8. data/README.md +65 -23
  9. data/assets/unique_digests_1.png +0 -0
  10. data/assets/unique_digests_2.png +0 -0
  11. data/examples/another_unique_job.rb +4 -2
  12. data/examples/custom_queue_job_with_filter_method.rb +1 -1
  13. data/examples/custom_queue_job_with_filter_proc.rb +1 -1
  14. data/examples/expiring_job.rb +1 -1
  15. data/examples/inline_worker.rb +1 -1
  16. data/examples/just_a_worker.rb +1 -1
  17. data/examples/long_running_job.rb +4 -2
  18. data/examples/main_job.rb +3 -2
  19. data/examples/my_unique_job.rb +4 -5
  20. data/examples/my_unique_job_with_filter_method.rb +3 -3
  21. data/examples/my_unique_job_with_filter_proc.rb +3 -3
  22. data/examples/notify_worker.rb +2 -2
  23. data/examples/simple_worker.rb +2 -2
  24. data/examples/unique_across_workers_job.rb +1 -1
  25. data/examples/unique_job_on_conflict_raise.rb +14 -0
  26. data/examples/unique_job_on_conflict_reject.rb +14 -0
  27. data/examples/unique_job_on_conflict_reschedule.rb +14 -0
  28. data/examples/unique_job_with_conditional_parameter.rb +3 -3
  29. data/examples/unique_job_with_filter_method.rb +5 -2
  30. data/examples/unique_job_with_nil_unique_args.rb +3 -3
  31. data/examples/unique_job_with_no_unique_args_method.rb +3 -3
  32. data/examples/unique_job_withthout_unique_args_parameter.rb +3 -3
  33. data/examples/unique_on_all_queues_job.rb +1 -1
  34. data/examples/until_and_while_executing_job.rb +4 -1
  35. data/examples/until_executed_2_job.rb +5 -5
  36. data/examples/until_executed_job.rb +5 -5
  37. data/examples/until_executing_job.rb +1 -1
  38. data/examples/until_expired_job.rb +1 -1
  39. data/examples/until_global_expired_job.rb +1 -1
  40. data/examples/while_executing_job.rb +2 -2
  41. data/examples/while_executing_reject_job.rb +2 -2
  42. data/examples/without_argument_job.rb +1 -1
  43. data/lib/sidekiq_unique_jobs.rb +30 -0
  44. data/lib/sidekiq_unique_jobs/client/middleware.rb +12 -1
  45. data/lib/sidekiq_unique_jobs/connection.rb +5 -1
  46. data/lib/sidekiq_unique_jobs/constants.rb +3 -0
  47. data/lib/sidekiq_unique_jobs/digests.rb +111 -0
  48. data/lib/sidekiq_unique_jobs/exceptions.rb +15 -16
  49. data/lib/sidekiq_unique_jobs/lock/base_lock.rb +44 -3
  50. data/lib/sidekiq_unique_jobs/lock/until_and_while_executing.rb +13 -3
  51. data/lib/sidekiq_unique_jobs/lock/until_executed.rb +8 -1
  52. data/lib/sidekiq_unique_jobs/lock/until_executing.rb +8 -1
  53. data/lib/sidekiq_unique_jobs/lock/until_expired.rb +14 -2
  54. data/lib/sidekiq_unique_jobs/lock/while_executing.rb +19 -5
  55. data/lib/sidekiq_unique_jobs/lock/while_executing_reject.rb +16 -63
  56. data/lib/sidekiq_unique_jobs/locksmith.rb +36 -13
  57. data/lib/sidekiq_unique_jobs/logging.rb +24 -1
  58. data/lib/sidekiq_unique_jobs/normalizer.rb +6 -0
  59. data/lib/sidekiq_unique_jobs/on_conflict.rb +24 -0
  60. data/lib/sidekiq_unique_jobs/on_conflict/log.rb +20 -0
  61. data/lib/sidekiq_unique_jobs/on_conflict/null_strategy.rb +16 -0
  62. data/lib/sidekiq_unique_jobs/on_conflict/raise.rb +17 -0
  63. data/lib/sidekiq_unique_jobs/on_conflict/reject.rb +72 -0
  64. data/lib/sidekiq_unique_jobs/on_conflict/reschedule.rb +24 -0
  65. data/lib/sidekiq_unique_jobs/on_conflict/strategy.rb +28 -0
  66. data/lib/sidekiq_unique_jobs/options_with_fallback.rb +19 -4
  67. data/lib/sidekiq_unique_jobs/scripts.rb +31 -0
  68. data/lib/sidekiq_unique_jobs/server/middleware.rb +10 -0
  69. data/lib/sidekiq_unique_jobs/sidekiq_worker_methods.rb +15 -1
  70. data/lib/sidekiq_unique_jobs/timeout/calculator.rb +17 -4
  71. data/lib/sidekiq_unique_jobs/unique_args.rb +47 -5
  72. data/lib/sidekiq_unique_jobs/unlockable.rb +10 -0
  73. data/lib/sidekiq_unique_jobs/util.rb +12 -7
  74. data/lib/sidekiq_unique_jobs/version.rb +1 -1
  75. data/lib/sidekiq_unique_jobs/web.rb +51 -0
  76. data/lib/sidekiq_unique_jobs/web/helpers.rb +37 -0
  77. data/lib/sidekiq_unique_jobs/web/views/unique_digest.erb +28 -0
  78. data/lib/sidekiq_unique_jobs/web/views/unique_digests.erb +42 -0
  79. data/redis/create.lua +4 -2
  80. data/redis/delete.lua +3 -1
  81. data/redis/delete_by_digest.lua +22 -0
  82. data/redis/signal.lua +3 -1
  83. data/sidekiq-unique-jobs.gemspec +2 -0
  84. metadata +49 -3
  85. data/lib/sidekiq_unique_jobs/lock/while_executing_requeue.rb +0 -21
@@ -4,11 +4,20 @@ require 'sidekiq_unique_jobs/server/middleware'
4
4
 
5
5
  module SidekiqUniqueJobs
6
6
  module Client
7
+ # The unique sidekiq middleware for the client push
8
+ #
9
+ # @author Mikael Henriksson <mikael@zoolutions.se>
7
10
  class Middleware
8
11
  include SidekiqUniqueJobs::Logging
9
12
  include OptionsWithFallback
10
13
 
11
- # :reek:LongParameterList { max_params: 4 }
14
+ # Calls this client middleware
15
+ # Used from Sidekiq.process_single
16
+ # @param [String] worker_class name of the sidekiq worker class
17
+ # @param [Hash] item a sidekiq job hash
18
+ # @param [String] queue name of the queue
19
+ # @param [Sidekiq::RedisConnection, ConnectionPool] redis_pool the redis connection
20
+ # @yield when uniqueness is disable or lock successful
12
21
  def call(worker_class, item, queue, redis_pool = nil)
13
22
  @worker_class = worker_class
14
23
  @item = item
@@ -20,6 +29,8 @@ module SidekiqUniqueJobs
20
29
 
21
30
  private
22
31
 
32
+ # The sidekiq job hash
33
+ # @return [Hash] the Sidekiq job hash
23
34
  attr_reader :item
24
35
 
25
36
  def success?
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SidekiqUniqueJobs
4
+ # Shared module for dealing with redis connections
5
+ #
6
+ # @author Mikael Henriksson <mikael@zoolutions.se>
4
7
  module Connection
5
8
  def self.included(base)
6
9
  base.send(:extend, self)
7
10
  end
8
11
 
9
- # :reek:UtilityFunction { enabled: false }
12
+ # Creates a connection to redis
13
+ # @return [Sidekiq::RedisConnection, ConnectionPool] a connection to redis
10
14
  def redis(redis_pool = nil)
11
15
  if redis_pool
12
16
  redis_pool.with { |conn| yield conn }
@@ -14,6 +14,9 @@ module SidekiqUniqueJobs
14
14
  UNIQUE_ARGS_KEY ||= 'unique_args'
15
15
  UNIQUE_DIGEST_KEY ||= 'unique_digest'
16
16
  UNIQUE_KEY ||= 'unique'
17
+ UNIQUE_SET ||= 'unique:keys'
18
+ LOCK_KEY ||= 'lock'
19
+ ON_CONFLICT_KEY ||= 'on_conflict'
17
20
  UNIQUE_ON_ALL_QUEUES_KEY ||= 'unique_on_all_queues' # TODO: Remove in v6.1
18
21
  UNIQUE_PREFIX_KEY ||= 'unique_prefix'
19
22
  end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SidekiqUniqueJobs
4
+ # Utility module to help manage unique digests in redis.
5
+ #
6
+ # @author Mikael Henriksson <mikael@zoolutions.se>
7
+ module Digests
8
+ DEFAULT_COUNT = 1_000
9
+ SCAN_PATTERN = '*'
10
+ CHUNK_SIZE = 100
11
+
12
+ include SidekiqUniqueJobs::Logging
13
+ include SidekiqUniqueJobs::Connection
14
+ extend self # rubocop:disable Style/ModuleFunction
15
+
16
+ # Return unique digests matching pattern
17
+ #
18
+ # @param [String] pattern a pattern to match with
19
+ # @param [Integer] count the maximum number to match
20
+ # @return [Array<String>] with unique digests
21
+ def all(pattern: SCAN_PATTERN, count: DEFAULT_COUNT)
22
+ redis { |conn| conn.sscan_each(UNIQUE_SET, match: pattern, count: count).to_a }
23
+ end
24
+
25
+ # Get a total count of unique digests
26
+ #
27
+ # @return [Integer] number of digests
28
+ def count
29
+ redis { |conn| conn.scard(UNIQUE_SET) }
30
+ end
31
+
32
+ # Deletes unique digest either by a digest or pattern
33
+ #
34
+ # @param [String] digest the full digest to delete
35
+ # @param [String] pattern a key pattern to match with
36
+ # @param [Integer] count the maximum number
37
+ # @raise [ArgumentError] when both pattern and digest are nil
38
+ # @return [Array<String>] with unique digests
39
+ def del(digest: nil, pattern: nil, count: DEFAULT_COUNT)
40
+ return delete_by_pattern(pattern, count: count) if pattern
41
+ return delete_by_digest(digest) if digest
42
+
43
+ raise ArgumentError, 'either digest or pattern need to be provided'
44
+ end
45
+
46
+ private
47
+
48
+ # Deletes unique digests by pattern
49
+ #
50
+ # @param [String] pattern a key pattern to match with
51
+ # @param [Integer] count the maximum number
52
+ # @return [Array<String>] with unique digests
53
+ def delete_by_pattern(pattern, count: DEFAULT_COUNT)
54
+ result, elapsed = timed do
55
+ digests = all(pattern: pattern, count: count)
56
+ batch_delete(digests)
57
+ digests.size
58
+ end
59
+
60
+ log_info("#{__method__}(#{pattern}, count: #{count}) completed in #{elapsed}ms")
61
+
62
+ result
63
+ end
64
+
65
+ # Get a total count of unique digests
66
+ #
67
+ # @param [String] digest a key pattern to match with
68
+ def delete_by_digest(digest)
69
+ result, elapsed = timed do
70
+ Scripts.call(:delete_by_digest, nil, keys: [UNIQUE_SET, digest])
71
+ count
72
+ end
73
+
74
+ log_info("#{__method__}(#{digest}) completed in #{elapsed}ms")
75
+
76
+ result
77
+ end
78
+
79
+ def batch_delete(digests) # rubocop:disable Metrics/MethodLength
80
+ redis do |conn|
81
+ digests.each_slice(CHUNK_SIZE) do |chunk|
82
+ conn.pipelined do
83
+ chunk.each do |digest|
84
+ conn.del digest
85
+ conn.srem(UNIQUE_SET, digest)
86
+ conn.del("#{digest}:EXISTS")
87
+ conn.del("#{digest}:GRABBED")
88
+ conn.del("#{digest}:VERSION")
89
+ conn.del("#{digest}:AVAILABLE")
90
+ conn.del("#{digest}:RUN:EXISTS")
91
+ conn.del("#{digest}:RUN:GRABBED")
92
+ conn.del("#{digest}:RUN:VERSION")
93
+ conn.del("#{digest}:RUN:AVAILABLE")
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ def timed
101
+ start = current_time
102
+ result = yield
103
+ elapsed = (current_time - start).round(2)
104
+ [result, elapsed]
105
+ end
106
+
107
+ def current_time
108
+ Time.now
109
+ end
110
+ end
111
+ end
@@ -1,30 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SidekiqUniqueJobs
4
- class LockTimeout < StandardError
5
- end
6
-
7
- class RunLockFailed < StandardError
4
+ # Error raised when a Lua script fails to execute
5
+ #
6
+ # @author Mikael Henriksson <mikael@zoolutions.se>
7
+ class Conflict < StandardError
8
+ def initialize(item)
9
+ super("Item with the key: #{item[UNIQUE_DIGEST_KEY]} is already scheduled or processing")
10
+ end
8
11
  end
9
12
 
13
+ # Error raised from {OnConflict::Raise}
14
+ #
15
+ # @author Mikael Henriksson <mikael@zoolutions.se>
10
16
  class ScriptError < StandardError
17
+ # @param [Symbol] file_name the name of the lua script
18
+ # @param [Redis::CommandError] source_exception exception to handle
11
19
  def initialize(file_name:, source_exception:)
12
20
  super("Problem compiling #{file_name}. Message: #{source_exception.message}")
13
21
  end
14
22
  end
15
23
 
16
- class UniqueKeyMissing < ArgumentError
17
- end
18
-
19
- class JidMissing < ArgumentError
20
- end
21
-
22
- class MaxLockTimeMissing < ArgumentError
23
- end
24
-
25
- class UnexpectedValue < StandardError
26
- end
27
-
24
+ # Error raised from {OptionsWithFallback#lock_class}
25
+ #
26
+ # @author Mikael Henriksson <mikael@zoolutions.se>
28
27
  class UnknownLock < StandardError
29
28
  end
30
29
  end
@@ -2,43 +2,80 @@
2
2
 
3
3
  module SidekiqUniqueJobs
4
4
  class Lock
5
+ # Abstract base class for locks
6
+ #
7
+ # @abstract
8
+ # @author Mikael Henriksson <mikael@zoolutions.se>
5
9
  class BaseLock
6
10
  include SidekiqUniqueJobs::Logging
7
11
 
12
+ # @param [Hash] item the Sidekiq job hash
13
+ # @param [Proc] callback the callback to use after unlock
14
+ # @param [Sidekiq::RedisConnection, ConnectionPool] redis_pool the redis connection
8
15
  def initialize(item, callback, redis_pool = nil)
9
16
  @item = prepare_item(item)
10
17
  @callback = callback
11
18
  @redis_pool = redis_pool
12
19
  end
13
20
 
21
+ # Handles locking of sidekiq jobs.
22
+ # Will call a conflict strategy if lock can't be achieved.
23
+ # @return [String] the sidekiq job id
14
24
  def lock
15
- locksmith.lock(item[LOCK_TIMEOUT_KEY])
25
+ if (token = locksmith.lock(item[LOCK_TIMEOUT_KEY]))
26
+ token
27
+ else
28
+ strategy.call
29
+ end
16
30
  end
17
31
 
32
+ # Execute the job in the Sidekiq server processor
33
+ # @raise [NotImplementedError] needs to be implemented in child class
18
34
  def execute
19
35
  raise NotImplementedError, "##{__method__} needs to be implemented in #{self.class}"
20
36
  end
21
37
 
38
+ # Unlocks the job from redis
39
+ # @return [String] sidekiq job id when successful
40
+ # @return [false] when unsuccessful
22
41
  def unlock
23
42
  locksmith.signal(item[JID_KEY]) # Only signal to release the lock
24
43
  end
25
44
 
45
+ # Deletes the job from redis if it is locked.
26
46
  def delete
27
47
  locksmith.delete # Soft delete (don't forcefully remove when expiration is set)
28
48
  end
29
49
 
50
+ # Forcefully deletes the job from redis.
51
+ # This is good for jobs when a previous lock was not unlocked
30
52
  def delete!
31
53
  locksmith.delete! # Force delete the lock
32
54
  end
33
55
 
56
+ # Checks if the item has achieved a lock
57
+ # @return [true] when this jid has locked the job
58
+ # @return [false] when this jid has not locked the job
34
59
  def locked?
35
60
  locksmith.locked?(item[JID_KEY])
36
61
  end
37
62
 
38
63
  private
39
64
 
40
- attr_reader :item, :redis_pool, :callback
65
+ # The sidekiq job hash
66
+ # @return [Hash] the Sidekiq job hash
67
+ attr_reader :item
41
68
 
69
+ # The sidekiq redis pool
70
+ # @return [Sidekiq::RedisConnection, ConnectionPool, NilClass] the redis connection
71
+ attr_reader :redis_pool
72
+
73
+ # The sidekiq job hash
74
+ # @return [Proc] the callback to use after unlock
75
+ attr_reader :callback
76
+
77
+ # The interface to the locking mechanism
78
+ # @return [SidekiqUniqueJobs::Locksmith]
42
79
  def locksmith
43
80
  @locksmith ||= SidekiqUniqueJobs::Locksmith.new(item, redis_pool)
44
81
  end
@@ -75,9 +112,13 @@ module SidekiqUniqueJobs
75
112
  def callback_safely
76
113
  callback&.call
77
114
  rescue StandardError
78
- log_warn("The lock for #{item[UNIQUE_DIGEST_KEY]} has been released but the #after_unlock callback failed!")
115
+ log_warn("The unique_key: #{item[UNIQUE_DIGEST_KEY]} has been unlocked but the #after_unlock callback failed!")
79
116
  raise
80
117
  end
118
+
119
+ def strategy
120
+ @strategy ||= OnConflict.find_strategy(item[ON_CONFLICT_KEY]).new(item)
121
+ end
81
122
  end
82
123
  end
83
124
  end
@@ -2,14 +2,24 @@
2
2
 
3
3
  module SidekiqUniqueJobs
4
4
  class Lock
5
+ # Locks jobs while the job is executing in the server process
6
+ # - Locks on perform_in or perform_async (see {UntilExecuting})
7
+ # - Unlocks before yielding to the worker's perform method (see {UntilExecuting})
8
+ # - Locks before yielding to the worker's perform method (see {WhileExecuting})
9
+ # - Unlocks after yielding to the worker's perform method (see {WhileExecuting})
10
+ #
11
+ # See {#lock} for more information about the client.
12
+ # See {#execute} for more information about the server
13
+ #
14
+ # @author Mikael Henriksson <mikael@zoolutions.se>
5
15
  class UntilAndWhileExecuting < BaseLock
16
+ # Executes in the Sidekiq server process
17
+ # @yield to the worker class perform method
6
18
  def execute
7
19
  return unless locked?
8
20
  unlock
9
21
 
10
- runtime_lock.execute do
11
- yield if block_given?
12
- end
22
+ runtime_lock.execute { yield }
13
23
  end
14
24
 
15
25
  def runtime_lock
@@ -2,12 +2,19 @@
2
2
 
3
3
  module SidekiqUniqueJobs
4
4
  class Lock
5
+ # Locks jobs until the server is done executing the job
6
+ # - Locks on perform_in or perform_async
7
+ # - Unlocks after yielding to the worker's perform method
8
+ #
9
+ # @author Mikael Henriksson <mikael@zoolutions.se>
5
10
  class UntilExecuted < BaseLock
6
11
  OK ||= 'OK'
7
12
 
13
+ # Executes in the Sidekiq server process
14
+ # @yield to the worker class perform method
8
15
  def execute
9
16
  return unless locked?
10
- with_cleanup { yield if block_given? }
17
+ with_cleanup { yield }
11
18
  end
12
19
  end
13
20
  end
@@ -2,10 +2,17 @@
2
2
 
3
3
  module SidekiqUniqueJobs
4
4
  class Lock
5
+ # Locks jobs until {#execute} starts
6
+ # - Locks on perform_in or perform_async
7
+ # - Unlocks after yielding to the worker's perform method
8
+ #
9
+ # @author Mikael Henriksson <mikael@zoolutions.se>
5
10
  class UntilExecuting < BaseLock
11
+ # Executes in the Sidekiq server process
12
+ # @yield to the worker class perform method
6
13
  def execute
7
14
  unlock_with_callback
8
- yield if block_given?
15
+ yield
9
16
  end
10
17
  end
11
18
  end
@@ -2,15 +2,27 @@
2
2
 
3
3
  module SidekiqUniqueJobs
4
4
  class Lock
5
+ # Locks jobs until the lock has expired
6
+ # - Locks on perform_in or perform_async
7
+ # - Unlocks when the expiration is hit
8
+ #
9
+ # See {#lock} for more information about the client.
10
+ # See {#execute} for more information about the server
11
+ #
12
+ # @author Mikael Henriksson <mikael@zoolutions.se>
5
13
  class UntilExpired < BaseLock
14
+ # Prevents these locks from being unlocked
15
+ # @return [true] always returns true
6
16
  def unlock
7
17
  true
8
18
  end
9
19
 
20
+ # Executes in the Sidekiq server process
21
+ # @yield to the worker class perform method
10
22
  def execute
11
23
  return unless locked?
12
- yield if block_given?
13
- # this lock does not handle after_unlock since we don't know when that would
24
+ yield
25
+ # this lock does not handle after_unlock since we don't know when that would happen
14
26
  end
15
27
  end
16
28
  end
@@ -2,24 +2,38 @@
2
2
 
3
3
  module SidekiqUniqueJobs
4
4
  class Lock
5
+ # Locks jobs while the job is executing in the server process
6
+ # - Locks before yielding to the worker's perform method
7
+ # - Unlocks after yielding to the worker's perform method
8
+ #
9
+ # See {#lock} for more information about the client.
10
+ # See {#execute} for more information about the server
11
+ #
12
+ # @author Mikael Henriksson <mikael@zoolutions.se>
5
13
  class WhileExecuting < BaseLock
6
14
  RUN_SUFFIX ||= ':RUN'
7
15
 
16
+ # @param [Hash] item the Sidekiq job hash
17
+ # @param [Proc] callback callback to call after unlock
18
+ # @param [Sidekiq::RedisConnection, ConnectionPool] redis_pool the redis connection
8
19
  def initialize(item, callback, redis_pool = nil)
9
20
  super(item, callback, redis_pool)
10
21
  append_unique_key_suffix
11
22
  end
12
23
 
13
- # Returning true makes sure the client
14
- # can push the job on the queue
24
+ # Simulate that a client lock was achieved.
25
+ # These locks should only ever be created in the server process.
26
+ # @return [true] always returns true
15
27
  def lock
16
28
  true
17
29
  end
18
30
 
19
- # Locks the job with the RUN_SUFFIX appended
31
+ # Executes in the Sidekiq server process.
32
+ # These jobs are locked in the server process not from the client
33
+ # @yield to the worker class perform method
20
34
  def execute
21
- return unless locksmith.lock(item[LOCK_TIMEOUT_KEY])
22
- with_cleanup { yield if block_given? }
35
+ return strategy.call unless locksmith.lock(item[LOCK_TIMEOUT_KEY])
36
+ with_cleanup { yield }
23
37
  end
24
38
 
25
39
  private