gcslock 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1068b280fd4e77bb1270880eaa80aa0145d64f35d58121329e8500ecc2321948
4
+ data.tar.gz: 33b5f11756a7c89f55384730322fd43aebcc810dff452c4cbf13bb05f2920aea
5
+ SHA512:
6
+ metadata.gz: de5e846755a1c556d263b6af0806b6e102bbd4c474aad7513aa3df8d84d6a8e1afb56b35cc3006e52f76508eac3bc691b5776175142f0e3d1cd9a0fb68ad40fa
7
+ data.tar.gz: fc5c9318fcdabb46acff6b89b54c9967ed13749c8cda75cc78699d063f233135a126013874667adc4e300c4975d82ad8da0971f1a972aed23ae01fdf236001b7
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ ._*
2
+ tmp/
3
+ Gemfile.lock
4
+ .bundle/config
5
+ .byebug_history
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # gcslock-ruby
2
+
3
+ This is inspired by [the Golang version](https://github.com/marcacohen/gcslock).
4
+
5
+ ## Google Cloud Storage setup
6
+
7
+ 1. Setup a new project at the [Google APIs Console](https://console.developers.google.com) and enable the Cloud Storage API.
8
+ 1. Install the [Google Cloud SDK](https://cloud.google.com/sdk/downloads) tool and configure your project and your OAuth credentials.
9
+ 1. Create a bucket in which to store your lock file using the command `gsutil mb gs://your-bucket-name`.
10
+ 1. Enable object versioning in your bucket using the command `gsutil versioning set on gs://your-bucket-name`.
11
+ 1. In your Ruby code, require `gcslock/mutex` and use it as follows:
12
+
13
+ ```ruby
14
+ require 'gcslock/mutex'
15
+
16
+ m = GCSLock::Mutex.new('your-bucket-name', 'my-file.lock')
17
+ m.synchronize do
18
+ // Protected and globally serialized computation happens here.
19
+ end
20
+ ```
21
+
data/bin/rspec ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # This file was generated by Bundler.
4
+ #
5
+ # The application 'rspec' is installed as part of a gem, and
6
+ # this file is here to facilitate running it.
7
+ #
8
+
9
+ require 'pathname'
10
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile",
11
+ Pathname.new(__FILE__).realpath)
12
+
13
+ require 'rubygems'
14
+ require 'bundler/setup'
15
+
16
+ load(Gem.bin_path('rspec-core', 'rspec'))
data/gcslock.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'gcslock/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'gcslock'
7
+ spec.version = GCSLock::VERSION
8
+ spec.authors = ['Raphaël Beamonte']
9
+ spec.email = ['raphael.beamonte@gmail.com']
10
+
11
+ spec.summary = 'Google Cloud Storage distributed locking'
12
+ spec.description = "Allows to use a Google Cloud Storage bucket as a distributed locking system"
13
+ spec.homepage = 'https://github.com/XaF/gcslock-ruby'
14
+ spec.license = 'MIT'
15
+
16
+ raise "RubyGems 2.0 or newer is required to protect against public gem pushes." unless spec.respond_to?(:metadata)
17
+ spec.metadata['allowed_push_host'] = "https://rubygems.org"
18
+
19
+ spec.files = %x(git ls-files -z).split("\x0").reject { |f| f.match(/^(test|DESIGN)/) }
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.required_ruby_version = '>= 2.0'
23
+
24
+ spec.add_runtime_dependency 'google-api-client'
25
+ spec.add_runtime_dependency 'google-cloud-storage', '~> 1.26.1'
26
+
27
+ spec.add_development_dependency 'rspec'
28
+ spec.add_development_dependency 'codecov'
29
+ end
@@ -0,0 +1,7 @@
1
+ module GCSLock
2
+ class Error < StandardError; end
3
+ class LockAlreadyOwnedError < Error; end
4
+ class LockNotOwnedError < Error; end
5
+ class LockNotFoundError < Error; end
6
+ class LockTimeoutError < Error; end
7
+ end
@@ -0,0 +1,97 @@
1
+ require 'google/cloud/storage'
2
+ require 'securerandom'
3
+
4
+ require_relative 'errors'
5
+
6
+ module GCSLock
7
+ class Mutex
8
+ def initialize(bucket, object, client: nil, uuid: nil, min_backoff: nil, max_backoff: nil)
9
+ @client = client || Google::Cloud::Storage.new
10
+ @bucket = @client.bucket(bucket, skip_lookup: true)
11
+ @object = @bucket.file(object, skip_lookup: true)
12
+
13
+ @uuid = uuid || SecureRandom.uuid
14
+ @min_backoff = min_backoff || 0.01
15
+ @max_backoff = max_backoff || 5.0
16
+ end
17
+
18
+ # Attempts to grab the lock and waits if it isn't available.
19
+ # Raises `ThreadError` if `mutex` was locked by the current thread.
20
+ def lock(timeout: nil)
21
+ raise LockAlreadyOwnedError, "Mutex for #{@object.name} is already owned by this process" if owned?
22
+
23
+ backoff = @min_backoff
24
+ waited = 0.0 unless timeout.nil?
25
+
26
+ loop do
27
+ return true if try_lock
28
+ break if !timeout.nil? && waited + backoff > timeout
29
+ sleep(backoff)
30
+
31
+ backoff_opts = [@max_backoff, backoff * 2]
32
+
33
+ unless timeout.nil?
34
+ waited += backoff
35
+ backoff_opts.push(timeout - waited) if timeout > waited
36
+ end
37
+
38
+ backoff = backoff_opts.min
39
+ end
40
+
41
+ raise LockTimeoutError, "Unable to get mutex for #{@object.name} before timeout"
42
+ end
43
+
44
+ # Returns `true` if this lock is currently held by some thread.
45
+ def locked?
46
+ @object.reload!
47
+ @object.exists?
48
+ end
49
+
50
+ # Returns `true` if this lock is currently held by current thread.
51
+ def owned?
52
+ locked? && @object.size == @uuid.size && @object.download.read == @uuid
53
+ end
54
+
55
+ # Obtains a lock, runs the block, and releases the lock when the block completes.
56
+ # Raises `LockAlreadyOwnedError` if the lock is already owned by the current instance.
57
+ def synchronize(timeout: nil)
58
+ raise LockAlreadyOwnedError, "Mutex for #{@object.name} is already owned by this process" if owned?
59
+
60
+ lock(timeout: timeout)
61
+ begin
62
+ yield
63
+ ensure
64
+ unlock
65
+ end
66
+ end
67
+
68
+ # Attempts to obtain the lock and returns immediately. Returns `true` if the lock was granted.
69
+ def try_lock
70
+ @client.service.service.insert_object(
71
+ @bucket.name,
72
+ name: @object.name,
73
+ if_generation_match: 0,
74
+ upload_source: StringIO.new(@uuid),
75
+ )
76
+
77
+ true
78
+ rescue Google::Apis::ClientError => e
79
+ raise unless e.status_code == 412 && e.message.start_with?('conditionNotMet:')
80
+
81
+ false
82
+ end
83
+
84
+ # Releases the lock. Raises `LockNotOwnedError` if the lock is not owned by the current instance.
85
+ def unlock
86
+ raise LockNotOwnedError, "Mutex for #{@object.name} is not owned by this process" unless owned?
87
+ @object.delete
88
+ end
89
+
90
+ # Releases the lock even if not owned by this instance. Raises `LockNotFoundError` if the lock cannot be found.
91
+ def unlock!
92
+ @object.delete
93
+ rescue Google::Cloud::NotFoundError => e
94
+ raise LockNotFoundError, "Mutex for #{@object.name} not found"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,3 @@
1
+ module GCSLock
2
+ VERSION = '1.0.0'.freeze
3
+ end
@@ -0,0 +1,308 @@
1
+ require 'spec_helper'
2
+ require 'gcslock/mutex'
3
+
4
+ describe GCSLock::Mutex do
5
+ before do
6
+ @bucket_name = 'bucket'
7
+ @object_name = 'object'
8
+
9
+ @gcs = instance_double(Google::Cloud::Storage::Project)
10
+ allow(Google::Cloud::Storage).to receive(:new).and_return(@gcs)
11
+
12
+ @bucket = instance_double(Google::Cloud::Storage::Bucket)
13
+ allow(@bucket).to receive(:name).and_return(@bucket_name)
14
+ allow(@gcs).to receive(:bucket).and_return(@bucket)
15
+
16
+ @object = instance_double(Google::Cloud::Storage::File)
17
+ allow(@object).to receive(:name).and_return(@object_name)
18
+ allow(@bucket).to receive(:file).and_return(@object)
19
+
20
+ @uuid = 'some_uuid'
21
+ allow(SecureRandom).to receive(:uuid).and_return(@uuid)
22
+ end
23
+
24
+ describe '.initialize' do
25
+ before do
26
+ @gcs = double(Google::Cloud::Storage)
27
+ allow(Google::Cloud::Storage).to receive(:new).and_return(@gcs)
28
+
29
+ @bucket = double(Google::Cloud::Storage::Bucket)
30
+ allow(@gcs).to receive(:bucket).and_return(@bucket)
31
+
32
+ @object = double(Google::Cloud::Storage::File)
33
+ allow(@bucket).to receive(:file).and_return(@object)
34
+ end
35
+
36
+ it 'initializes in GCS client when none provided' do
37
+ expect(Google::Cloud::Storage).to receive(:new).once
38
+
39
+ GCSLock::Mutex.new(@bucket_name, @object_name)
40
+ end
41
+
42
+ it 'initializes in GCS client when none provided' do
43
+ expect(Google::Cloud::Storage).not_to receive(:new)
44
+
45
+ GCSLock::Mutex.new(@bucket_name, @object_name, client: @gcs)
46
+ end
47
+
48
+ it 'initializes a bucket with lazy loading' do
49
+ expect(Google::Cloud::Storage).to receive(:new).once
50
+ expect(@gcs).to receive(:bucket).with(@bucket_name, skip_lookup: true).once
51
+
52
+ GCSLock::Mutex.new(@bucket_name, @object_name)
53
+ end
54
+
55
+ it 'initializes a file with lazy loading' do
56
+ expect(Google::Cloud::Storage).to receive(:new).once
57
+ expect(@bucket).to receive(:file).with(@object_name, skip_lookup: true).once
58
+
59
+ GCSLock::Mutex.new(@bucket_name, @object_name)
60
+ end
61
+
62
+ it 'initializes a randomly generated unique ID' do
63
+ expect(SecureRandom).to receive(:uuid).once
64
+
65
+ GCSLock::Mutex.new(@bucket_name, @object_name)
66
+ end
67
+ end
68
+
69
+ context 'initialized' do
70
+ before do
71
+ @mutex = GCSLock::Mutex.new(@bucket_name, @object_name)
72
+ end
73
+
74
+ describe '.lock' do
75
+ it 'sleeps and retry when failing on the first try_lock' do
76
+ expect(@mutex).to receive(:owned?).once.and_return(false)
77
+ expect(@mutex).to receive(:try_lock).once.and_return(false)
78
+ expect(@mutex).to receive(:sleep).once
79
+ expect(@mutex).to receive(:try_lock).once.and_return(true)
80
+
81
+ @mutex.lock(timeout: 2)
82
+ end
83
+
84
+ it 'sleeps just the time needed to retry once at the end' do
85
+ expect(@mutex).to receive(:owned?).once.and_return(false)
86
+ expect(@mutex).to receive(:sleep).exactly(2).times
87
+ expect(@mutex).to receive(:try_lock).exactly(3).times.and_return(false)
88
+
89
+ expect do
90
+ @mutex.lock(timeout: 0.03)
91
+ end.to raise_error(GCSLock::LockTimeoutError)
92
+ end
93
+
94
+ it 'raises an error if unable to get the lock when reaching the timeout' do
95
+ expect(@mutex).to receive(:owned?).once.and_return(false)
96
+ expect(@mutex).to receive(:try_lock).once.and_return(false)
97
+
98
+ expect do
99
+ @mutex.lock(timeout: 0)
100
+ end.to raise_error(GCSLock::LockTimeoutError)
101
+ end
102
+
103
+ it 'raises an error if the lock is already owned' do
104
+ expect(@mutex).to receive(:owned?).once.and_return(true)
105
+ expect(@mutex).not_to receive(:try_lock)
106
+
107
+ expect do
108
+ @mutex.lock
109
+ end.to raise_error(GCSLock::LockAlreadyOwnedError)
110
+ end
111
+ end
112
+
113
+ describe '.locked?' do
114
+ before do
115
+ allow(@object).to receive(:reload!)
116
+ allow(@object).to receive(:exists?)
117
+ end
118
+
119
+ it 'calls reload! on the lock file' do
120
+ expect(@object).to receive(:reload!).once
121
+
122
+ @mutex.locked?
123
+ end
124
+
125
+ it 'calls exists? on the lock file' do
126
+ expect(@object).to receive(:exists?).once
127
+
128
+ @mutex.locked?
129
+ end
130
+
131
+ it 'returns true if exists is true' do
132
+ expect(@object).to receive(:exists?).once.and_return(true)
133
+
134
+ expect(@mutex.locked?).to be(true)
135
+ end
136
+
137
+ it 'returns false if exists is false' do
138
+ expect(@object).to receive(:exists?).once.and_return(false)
139
+
140
+ expect(@mutex.locked?).to be(false)
141
+ end
142
+ end
143
+
144
+ describe '.owned?' do
145
+ it 'returns false if mutex is not locked' do
146
+ expect(@mutex).to receive(:locked?).once.and_return(false)
147
+
148
+ expect(@mutex.owned?).to be(false)
149
+ end
150
+
151
+ it 'returns false if mutex is locked but object size != uuid size' do
152
+ expect(@mutex).to receive(:locked?).once.and_return(true)
153
+ expect(@object).to receive(:size).once.and_return(@uuid.size + 10)
154
+
155
+ expect(@mutex.owned?).to be(false)
156
+ end
157
+
158
+ it 'returns false if mutex is locked and object size == uuid size but object content != uuid' do
159
+ expect(@mutex).to receive(:locked?).once.and_return(true)
160
+ expect(@object).to receive(:size).once.and_return(@uuid.size)
161
+
162
+ download = StringIO.new('blah')
163
+ expect(@object).to receive(:download).once.and_return(download)
164
+
165
+ expect(@mutex.owned?).to be(false)
166
+ end
167
+
168
+ it 'returns true if mutex is locked and object contains uuid' do
169
+ expect(@mutex).to receive(:locked?).once.and_return(true)
170
+ expect(@object).to receive(:size).once.and_return(@uuid.size)
171
+
172
+ download = StringIO.new(@uuid)
173
+ expect(@object).to receive(:download).once.and_return(download)
174
+
175
+ expect(@mutex.owned?).to be(true)
176
+ end
177
+ end
178
+
179
+ describe '.synchronize' do
180
+ it 'locks, yields and unlock the mutex' do
181
+ expect(@mutex).to receive(:owned?).once.and_return(false)
182
+ expect(@mutex).to receive(:lock).once.and_return(true)
183
+ expect(@mutex).to receive(:unlock).once
184
+
185
+ has_yielded = false
186
+ @mutex.synchronize do
187
+ has_yielded = true
188
+ end
189
+
190
+ expect(has_yielded).to be(true)
191
+ end
192
+
193
+ it 'raises an error if the lock is already owned' do
194
+ expect(@mutex).to receive(:owned?).once.and_return(true)
195
+ expect(@mutex).not_to receive(:lock)
196
+ expect(@mutex).not_to receive(:unlock)
197
+
198
+ has_yielded = false
199
+
200
+ expect do
201
+ @mutex.synchronize do
202
+ has_yielded = true
203
+ end
204
+ end.to raise_error(GCSLock::LockAlreadyOwnedError)
205
+
206
+ expect(has_yielded).to be(false)
207
+ end
208
+ end
209
+
210
+ describe '.try_lock' do
211
+ before do
212
+ @service = instance_double(Google::Cloud::Storage::Service)
213
+ allow(@gcs).to receive(:service).and_return(@service)
214
+
215
+ @servicev1 = instance_double(Google::Apis::StorageV1::StorageService)
216
+ allow(@service).to receive(:service).and_return(@servicev1)
217
+ end
218
+
219
+ it 'returns true if lock obtained' do
220
+ expect(@servicev1).to receive(:insert_object).with(
221
+ @bucket_name,
222
+ name: @object_name,
223
+ if_generation_match: 0,
224
+ upload_source: instance_of(StringIO),
225
+ ).once
226
+
227
+ expect(@mutex.try_lock).to be(true)
228
+ end
229
+
230
+ it 'returns false if lock already taken (precondition failed)' do
231
+ client_error = Google::Apis::ClientError.new('conditionNotMet: Precondition failed', status_code: 412)
232
+
233
+ expect(@servicev1).to receive(:insert_object).with(
234
+ @bucket_name,
235
+ name: @object_name,
236
+ if_generation_match: 0,
237
+ upload_source: instance_of(StringIO),
238
+ ).once.and_raise(client_error)
239
+
240
+ expect(@mutex.try_lock).to be(false)
241
+ end
242
+
243
+ it 'raises in case of precondition failed for other reason than conditionNotMet' do
244
+ client_error = Google::Apis::ClientError.new('blah: Precondition failed', status_code: 412)
245
+
246
+ expect(@servicev1).to receive(:insert_object).with(
247
+ @bucket_name,
248
+ name: @object_name,
249
+ if_generation_match: 0,
250
+ upload_source: instance_of(StringIO),
251
+ ).once.and_raise(client_error)
252
+
253
+ expect do
254
+ @mutex.try_lock
255
+ end.to raise_error(client_error)
256
+ end
257
+
258
+ it 'raises in case of other error than precondition failed' do
259
+ client_error = Google::Apis::ClientError.new('blah', status_code: 400)
260
+
261
+ expect(@servicev1).to receive(:insert_object).with(
262
+ @bucket_name,
263
+ name: @object_name,
264
+ if_generation_match: 0,
265
+ upload_source: instance_of(StringIO),
266
+ ).once.and_raise(client_error)
267
+
268
+ expect do
269
+ @mutex.try_lock
270
+ end.to raise_error(client_error)
271
+ end
272
+ end
273
+
274
+ describe '.unlock' do
275
+ it 'calls delete on the object if lock is owned' do
276
+ expect(@mutex).to receive(:owned?).once.and_return(true)
277
+ expect(@object).to receive(:delete).once
278
+
279
+ @mutex.unlock
280
+ end
281
+
282
+ it 'raises an error if the lock is not owned' do
283
+ expect(@mutex).to receive(:owned?).once.and_return(false)
284
+ expect(@object).not_to receive(:delete)
285
+
286
+ expect do
287
+ @mutex.unlock
288
+ end.to raise_error(GCSLock::LockNotOwnedError)
289
+ end
290
+ end
291
+
292
+ describe '.unlock!' do
293
+ it 'calls delete on the object' do
294
+ expect(@object).to receive(:delete).once
295
+
296
+ @mutex.unlock!
297
+ end
298
+
299
+ it 'raises an error if the object is not found' do
300
+ expect(@object).to receive(:delete).once.and_raise(Google::Cloud::NotFoundError.new('blah'))
301
+
302
+ expect do
303
+ @mutex.unlock!
304
+ end.to raise_error(GCSLock::LockNotFoundError)
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,9 @@
1
+ if ENV['COVERAGE'] || ENV['CI'] == 'true'
2
+ require 'simplecov'
3
+
4
+ SimpleCov.start
5
+ if ENV['CI'] == 'true'
6
+ require 'codecov'
7
+ SimpleCov.formatter = SimpleCov::Formatter::Codecov
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gcslock
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Raphaël Beamonte
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-05-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: google-api-client
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: google-cloud-storage
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.26.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.26.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: codecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: Allows to use a Google Cloud Storage bucket as a distributed locking
70
+ system
71
+ email:
72
+ - raphael.beamonte@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".gitignore"
78
+ - Gemfile
79
+ - README.md
80
+ - bin/rspec
81
+ - gcslock.gemspec
82
+ - lib/gcslock/errors.rb
83
+ - lib/gcslock/mutex.rb
84
+ - lib/gcslock/version.rb
85
+ - spec/gcslock/mutex_spec.rb
86
+ - spec/spec_helper.rb
87
+ homepage: https://github.com/XaF/gcslock-ruby
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ allowed_push_host: https://rubygems.org
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '2.0'
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.0.3
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: Google Cloud Storage distributed locking
111
+ test_files: []