simple_mutex 1.0.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.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SimpleMutex
6
+ class Helper
7
+ LIST_MODES = %i[job batch default all].freeze
8
+
9
+ class << self
10
+ def get(lock_key)
11
+ new.get(lock_key)
12
+ end
13
+
14
+ def list(**options)
15
+ new.list(**options)
16
+ end
17
+ end
18
+
19
+ def get(lock_key)
20
+ raw_data = redis.get(lock_key)
21
+
22
+ return if raw_data.nil?
23
+
24
+ parsed_data = safe_parse(raw_data)
25
+
26
+ {
27
+ key: lock_key,
28
+ value: parsed_data.nil? ? raw_data : parsed_data,
29
+ }
30
+ end
31
+
32
+ # rubocop:disable Metrics/MethodLength, Style/HashEachMethods, Performance/CollectionLiteralInLoop
33
+ def list(mode: :default)
34
+ check_mode(mode)
35
+
36
+ result = []
37
+
38
+ redis.keys.each do |lock_key|
39
+ redis.watch(lock_key) do
40
+ raw_data = redis.get(lock_key)
41
+
42
+ unless raw_data.nil?
43
+ parsed_data = safe_parse(raw_data)
44
+
45
+ if parsed_data.nil?
46
+ result << { key: lock_key, value: raw_data } if mode == :all
47
+ else
48
+ lock_type = parsed_data&.dig("payload", "type")
49
+
50
+ if (mode == :all) ||
51
+ (lock_type == "Job" && %i[job default].include?(mode)) ||
52
+ (lock_type == "Batch" && %i[batch default].include?(mode))
53
+ result << { key: lock_key, value: parsed_data }
54
+ end
55
+ end
56
+ end
57
+
58
+ redis.unwatch
59
+ end
60
+ end
61
+
62
+ result
63
+ end
64
+ # rubocop:enable Metrics/MethodLength, Style/HashEachMethods, Performance/CollectionLiteralInLoop
65
+
66
+ private
67
+
68
+ def check_mode(mode)
69
+ return if LIST_MODES.include?(mode)
70
+ raise ::SimpleMutex::Error, "invalid mode ( only [:job, :batch, :default, :all] allowed)."
71
+ end
72
+
73
+ def redis
74
+ ::SimpleMutex.redis
75
+ end
76
+
77
+ def safe_parse(raw_data)
78
+ JSON.parse(raw_data)
79
+ rescue JSON::ParserError
80
+ nil
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module SimpleMutex
7
+ class Mutex
8
+ DEFAULT_EXPIRES_IN = 60 * 60 # 1 hour
9
+
10
+ ERR_MSGS = {
11
+ unlock: {
12
+ unknown: lambda do |lock_key|
13
+ "something when wrong when deleting lock key <#{lock_key}>."
14
+ end,
15
+ key_not_found: lambda do |lock_key|
16
+ "lock not found for lock key <#{lock_key}>."
17
+ end,
18
+ signature_mismatch: lambda do |lock_key|
19
+ "signature mismatch for lock key <#{lock_key}>."
20
+ end,
21
+ }.freeze,
22
+ lock: {
23
+ basic: lambda do |lock_key|
24
+ "failed to acquire lock <#{lock_key}>."
25
+ end,
26
+ }.freeze,
27
+ }.freeze
28
+
29
+ BaseError = Class.new(::StandardError) do
30
+ attr_reader :lock_key
31
+
32
+ def initialize(msg, lock_key)
33
+ @lock_key = lock_key
34
+ super(msg)
35
+ end
36
+ end
37
+
38
+ LockError = Class.new(BaseError)
39
+ UnlockError = Class.new(BaseError)
40
+
41
+ class << self
42
+ attr_accessor :redis
43
+
44
+ def lock(lock_key, **options)
45
+ new(lock_key, **options).lock
46
+ end
47
+
48
+ def lock!(lock_key, **options)
49
+ new(lock_key, **options).lock!
50
+ end
51
+
52
+ def unlock(lock_key, signature: nil, force: false)
53
+ ::SimpleMutex.redis_check!
54
+
55
+ redis = ::SimpleMutex.redis
56
+
57
+ redis.watch(lock_key) do
58
+ raw_data = redis.get(lock_key)
59
+
60
+ if raw_data && (force || signature_valid?(raw_data, signature))
61
+ redis.multi { |multi| multi.del(lock_key) }.first.positive?
62
+ else
63
+ redis.unwatch
64
+ false
65
+ end
66
+ end
67
+ end
68
+
69
+ def unlock!(lock_key, signature: nil, force: false)
70
+ ::SimpleMutex.redis_check!
71
+
72
+ redis = ::SimpleMutex.redis
73
+
74
+ redis.watch(lock_key) do
75
+ raw_data = redis.get(lock_key)
76
+
77
+ begin
78
+ raise_error(UnlockError, :key_not_found, lock_key) unless raw_data
79
+
80
+ unless force || signature_valid?(raw_data, signature)
81
+ raise_error(UnlockError, :signature_mismatch, lock_key)
82
+ end
83
+
84
+ success = redis.multi { |multi| multi.del(lock_key) }.first.positive?
85
+
86
+ raise_error(UnlockError, :unknown, lock_key) unless success
87
+ ensure
88
+ redis.unwatch
89
+ end
90
+ end
91
+ end
92
+
93
+ def with_lock(lock_key, **options, &block)
94
+ new(lock_key, **options).with_lock(&block)
95
+ end
96
+
97
+ def raise_error(error_class, msg_template, lock_key)
98
+ template_base = error_class.name.split("::").last.gsub("Error", "").downcase.to_sym
99
+ error_msg = ERR_MSGS[template_base][msg_template].call(lock_key)
100
+
101
+ raise(error_class.new(error_msg, lock_key))
102
+ end
103
+
104
+ def signature_valid?(raw_data, signature)
105
+ return false if raw_data.nil?
106
+
107
+ JSON.parse(raw_data)["signature"] == signature
108
+ rescue JSON::ParseError, TypeError
109
+ false
110
+ end
111
+ end
112
+
113
+ attr_reader :lock_key, :expires_in, :signature, :payload
114
+
115
+ def initialize(lock_key,
116
+ expires_in: DEFAULT_EXPIRES_IN,
117
+ signature: SecureRandom.uuid,
118
+ payload: nil)
119
+ ::SimpleMutex.redis_check!
120
+
121
+ self.lock_key = lock_key
122
+ self.expires_in = expires_in.to_i
123
+ self.signature = signature
124
+ self.payload = payload
125
+ end
126
+
127
+ def lock
128
+ !!redis.set(lock_key, generate_data, nx: true, ex: expires_in)
129
+ end
130
+
131
+ def unlock(force: false)
132
+ self.class.unlock(lock_key, signature: signature, force: force)
133
+ end
134
+
135
+ def with_lock
136
+ lock!
137
+
138
+ begin
139
+ yield
140
+ ensure
141
+ unlock
142
+ end
143
+ end
144
+
145
+ def lock_obtained?
146
+ self.class.signature_valid?(redis.get(lock_key), signature)
147
+ end
148
+
149
+ def lock!
150
+ lock or raise_error(LockError, :basic)
151
+ end
152
+
153
+ def unlock!(force: false)
154
+ self.class.unlock!(lock_key, signature: signature, force: force)
155
+ end
156
+
157
+ private
158
+
159
+ attr_writer :lock_key, :expires_in, :signature, :payload
160
+
161
+ def generate_data
162
+ JSON.generate(
163
+ "signature" => signature,
164
+ "created_at" => Time.now.to_s,
165
+ "payload" => payload,
166
+ )
167
+ end
168
+
169
+ def redis
170
+ ::SimpleMutex.redis
171
+ end
172
+
173
+ def raise_error(error_class, msg_template)
174
+ self.class.raise_error(error_class, msg_template, lock_key)
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module SimpleMutex
6
+ module SidekiqSupport
7
+ class Batch
8
+ extend Forwardable
9
+
10
+ DEFAULT_EXPIRES_IN = 6 * 60 * 60
11
+
12
+ class Error < StandardError; end
13
+
14
+ attr_reader :batch, :lock_key, :expires_in
15
+
16
+ def_delegators :@batch, :on, :bid, :description, :description=
17
+
18
+ def initialize(lock_key:, expires_in: DEFAULT_EXPIRES_IN)
19
+ ::SimpleMutex.sidekiq_pro_check!
20
+
21
+ self.lock_key = lock_key
22
+ self.expires_in = expires_in
23
+ self.batch = ::Sidekiq::Batch.new
24
+ end
25
+
26
+ def jobs(&block)
27
+ mutex.lock!
28
+
29
+ set_callbacks(mutex.signature)
30
+
31
+ begin
32
+ batch.jobs(&block)
33
+ rescue => error
34
+ mutex.unlock!
35
+ raise error
36
+ end
37
+
38
+ status = ::Sidekiq::Batch::Status.new(batch.bid)
39
+
40
+ if status.total.zero?
41
+ mutex.unlock!
42
+ raise Error, "Batch should contain at least one job."
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ attr_writer :batch, :lock_key, :expires_in
49
+
50
+ def mutex
51
+ return @mutex if defined? @mutex
52
+
53
+ @mutex = ::SimpleMutex::Mutex.new(
54
+ lock_key,
55
+ expires_in: expires_in,
56
+ payload: generate_payload(batch),
57
+ )
58
+ end
59
+
60
+ def generate_payload(batch)
61
+ { "type" => "Batch",
62
+ "started_at" => Time.now.to_s,
63
+ "bid" => batch.bid }
64
+ end
65
+
66
+ def set_callbacks(signature)
67
+ %i[death success].each do |event|
68
+ batch.on(
69
+ event,
70
+ ::SimpleMutex::SidekiqSupport::BatchCallbacks,
71
+ "lock_key" => lock_key,
72
+ "signature" => signature,
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMutex
4
+ module SidekiqSupport
5
+ class BatchCallbacks
6
+ def on_death(_status, options)
7
+ ::SimpleMutex::Mutex.unlock!(options["lock_key"], signature: options["signature"])
8
+ end
9
+
10
+ def on_success(_status, options)
11
+ ::SimpleMutex::Mutex.unlock!(options["lock_key"], signature: options["signature"])
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMutex
4
+ module SidekiqSupport
5
+ class BatchCleaner < ::SimpleMutex::BaseCleaner
6
+ class << self
7
+ def unlock_dead_batches
8
+ new.unlock
9
+ end
10
+ end
11
+
12
+ def initialize
13
+ ::SimpleMutex.sidekiq_pro_check!
14
+ super
15
+ end
16
+
17
+ private
18
+
19
+ def type
20
+ "Batch"
21
+ end
22
+
23
+ def path_to_entity_id
24
+ %w[payload bid]
25
+ end
26
+
27
+ def active_entity_ids
28
+ ::Sidekiq::BatchSet.new.map(&:bid)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMutex
4
+ module SidekiqSupport
5
+ class JobCleaner < ::SimpleMutex::BaseCleaner
6
+ class << self
7
+ def unlock_dead_jobs
8
+ new.unlock
9
+ end
10
+ end
11
+
12
+ private
13
+
14
+ def type
15
+ "Job"
16
+ end
17
+
18
+ def path_to_entity_id
19
+ %w[payload jid]
20
+ end
21
+
22
+ def active_entity_ids
23
+ ::Sidekiq::Workers.new.map { |_pid, _tid, work| work["payload"]["jid"] }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMutex
4
+ module SidekiqSupport
5
+ module JobMixin
6
+ def self.included(klass)
7
+ klass.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def locking!
12
+ @locking = true
13
+ end
14
+
15
+ def locking?
16
+ !!@locking
17
+ end
18
+
19
+ def skip_locking_error!
20
+ @skip_locking_error = true
21
+ end
22
+
23
+ def skip_locking_error?
24
+ !!@skip_locking_error
25
+ end
26
+
27
+ def lock_with_params!
28
+ @lock_with_params = true
29
+ end
30
+
31
+ def lock_with_params?
32
+ !!@lock_with_params
33
+ end
34
+
35
+ def set_job_timeout(value)
36
+ @job_timeout = value
37
+ end
38
+
39
+ def job_timeout
40
+ @job_timeout
41
+ end
42
+ end
43
+
44
+ def with_redlock(args = [], &block)
45
+ return yield unless self.class.locking?
46
+
47
+ options = {
48
+ params: args,
49
+ lock_with_params: self.class.lock_with_params?,
50
+ }
51
+
52
+ options[:expires_in] = self.class.job_timeout unless self.class.job_timeout.nil?
53
+
54
+ ::SimpleMutex::SidekiqSupport::JobWrapper.new(self, **options).with_redlock(&block)
55
+ rescue SimpleMutex::Mutex::LockError => error
56
+ process_locking_error(error)
57
+ end
58
+
59
+ # override for custom processing
60
+ def process_locking_error(error)
61
+ raise error unless self.class.skip_locking_error?
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMutex
4
+ module SidekiqSupport
5
+ class JobWrapper
6
+ attr_reader :job, :params, :lock_key, :lock_with_params, :expires_in
7
+
8
+ DEFAULT_EXPIRES_IN = 5 * 60 * 60 # 5 hours
9
+
10
+ def initialize(job,
11
+ params: [],
12
+ lock_key: nil,
13
+ lock_with_params: false,
14
+ expires_in: DEFAULT_EXPIRES_IN)
15
+ self.job = job
16
+ self.params = params
17
+
18
+ self.lock_key = lock_key
19
+ self.lock_with_params = lock_with_params
20
+ self.expires_in = expires_in
21
+ end
22
+
23
+ def with_redlock(&block)
24
+ ::SimpleMutex::Mutex.with_lock(
25
+ lock_key || generate_lock_key,
26
+ expires_in: expires_in,
27
+ payload: generate_payload,
28
+ &block
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ attr_writer :job, :params, :lock_key, :lock_with_params, :expires_in
35
+
36
+ def generate_lock_key
37
+ key = if lock_with_params
38
+ "#{job.class.name}<#{params.to_json}>"
39
+ else
40
+ job.class.name
41
+ end
42
+ key.tr(":", "_")
43
+ end
44
+
45
+ def generate_payload
46
+ { "type" => "Job",
47
+ "started_at" => Time.now.to_s,
48
+ "jid" => job.jid }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMutex
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMutex
4
+ require_relative "simple_mutex/version"
5
+ require_relative "simple_mutex/mutex"
6
+
7
+ require_relative "simple_mutex/base_cleaner"
8
+
9
+ require_relative "simple_mutex/sidekiq_support/job_wrapper"
10
+ require_relative "simple_mutex/sidekiq_support/job_cleaner"
11
+ require_relative "simple_mutex/sidekiq_support/job_mixin"
12
+
13
+ require_relative "simple_mutex/sidekiq_support/batch"
14
+ require_relative "simple_mutex/sidekiq_support/batch_callbacks"
15
+ require_relative "simple_mutex/sidekiq_support/batch_cleaner"
16
+
17
+ require_relative "simple_mutex/helper"
18
+
19
+ class Error < StandardError; end
20
+
21
+ class << self
22
+ attr_accessor :redis, :logger
23
+ end
24
+
25
+ def redis_check!
26
+ raise Error, no_redis_error unless redis
27
+ end
28
+
29
+ def sidekiq_pro_check!
30
+ raise Error, no_sidekiq_pro_error unless sidekiq_pro_installed?
31
+ end
32
+
33
+ def sidekiq_pro_installed?
34
+ Object.const_defined?("Sidekiq::Pro::VERSION")
35
+ end
36
+
37
+ def no_redis_error
38
+ "You should set SimpleMutex.redis before using any functions of this gem."
39
+ end
40
+
41
+ def no_sidekiq_pro_error
42
+ "Batch related functionality requires Sidekiq Pro to be installed."
43
+ end
44
+
45
+ module_function :redis_check!, :sidekiq_pro_check!, :sidekiq_pro_installed?,
46
+ :no_redis_error, :no_sidekiq_pro_error
47
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/simple_mutex/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "simple_mutex"
7
+ spec.version = SimpleMutex::VERSION
8
+ spec.authors = ["bob-umbr"]
9
+ spec.email = ["bob@umbrellio.biz"]
10
+
11
+ spec.summary = "Redis-based mutex library for using with sidekiq jobs and batches."
12
+ spec.description = "Redis-based mutex library for using with sidekiq jobs and batches."
13
+ spec.homepage = "https://github.com/umbrellio/simple_mutex"
14
+ spec.license = "MIT"
15
+
16
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
+ end
23
+
24
+ spec.bindir = "exe"
25
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_runtime_dependency "redis"
29
+ spec.add_runtime_dependency "redis-namespace"
30
+ spec.add_runtime_dependency "sidekiq"
31
+
32
+ spec.add_development_dependency "bundler"
33
+ spec.add_development_dependency "bundler-audit"
34
+ spec.add_development_dependency "mock_redis"
35
+ spec.add_development_dependency "rspec"
36
+ spec.add_development_dependency "rubocop"
37
+ spec.add_development_dependency "rubocop-config-umbrellio"
38
+ spec.add_development_dependency "rubocop-rspec"
39
+ spec.add_development_dependency "timecop"
40
+ end