gcslock 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.
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: []