suo 0.1.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 0688005f9b0d058a3aa50d0225cf15c52b74e036
4
- data.tar.gz: 65a1be72254409473e51e7455a8e718db505c87a
3
+ metadata.gz: 413b62af263af5a28c0fbf5691b30963f42f0883
4
+ data.tar.gz: 546c7fd3bac7b2222f6d9d2701a456c6a2aa6e36
5
5
  SHA512:
6
- metadata.gz: 8e8f222bb46a28971467792d8b75c939d8e62869860a53dffa587e85cd2d9075211e229c670d14868730b714dcd2ac8db2e2a3b4666717238450a8f6022772f5
7
- data.tar.gz: 0671a1a94f678a9da525c50b756fee60b3a8e207de45693bfe79deddf9d1cf333d0be0130e166c0c1ef4a088f9e6d95810bdd955fd6d5a0c7e83df20fe5b98ca
6
+ metadata.gz: eca791c152ebf80a02307f7af951eb352b8891e1bf8ec75e674ca9db772c317df2bcdb63bc880f1c844a52f0246d0448aab6937bae9d914bf0c85c886714f61d
7
+ data.tar.gz: 721d0b259f9c87338227ec70e5d2a20b72672ab71d0402984c1e1afceb2dc71eaa3e274a50e1760ef4764220c0184a4994c0c37c6b4760579a346c91b55de6ab
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 0.2.0
2
+
3
+ - Refactor class methods into instance methods to simplify implementation.
4
+ - Increase thread safety with Memcached implementation.
5
+
1
6
  ## 0.1.3
2
7
 
3
8
  - Properly throw Suo::LockClientError when the connection itself fails (Memcache server not reachable, etc.)
data/README.md CHANGED
@@ -31,12 +31,22 @@ suo.lock("some_key") do
31
31
  @puppies.pet!
32
32
  end
33
33
 
34
- 2.times do
35
- Thread.new do
36
- # second argument is the number of resources - so this will run twice
37
- suo.lock("other_key", 2, timeout: 0.5) { puts "Will run twice!" }
38
- end
39
- end
34
+ Thread.new { suo.lock("other_key", 2) { puts "One"; sleep 2 } }
35
+ Thread.new { suo.lock("other_key", 2) { puts "Two"; sleep 2 } }
36
+ Thread.new { suo.lock("other_key", 2) { puts "Three" } }
37
+
38
+ # will print "One" "Two", but not "Three", as there are only 2 resources
39
+
40
+ # custom acquisition timeouts (time to acquire)
41
+ suo = Suo::Client::Memcached.new(client: some_dalli_client, acquisition_timeout: 1) # in seconds
42
+
43
+ # manually locking/unlocking
44
+ suo.lock("a_key")
45
+ foo.baz!
46
+ suo.unlock("a_key")
47
+
48
+ # custom stale lock cleanup (cleaning of dead clients)
49
+ suo = Suo::Client::Redis.new(client: some_redis_client, stale_lock_expiration: 60*5)
40
50
  ```
41
51
 
42
52
  ## TODO
@@ -1,206 +1,188 @@
1
1
  module Suo
2
2
  module Client
3
3
  class Base
4
-
5
4
  DEFAULT_OPTIONS = {
6
- retry_timeout: 0.1,
7
- retry_delay: 0.01,
5
+ acquisition_timeout: 0.1,
6
+ acquisition_delay: 0.01,
8
7
  stale_lock_expiration: 3600
9
8
  }.freeze
10
9
 
10
+ attr_accessor :client
11
+
12
+ include MonitorMixin
13
+
11
14
  def initialize(options = {})
12
- @options = self.class.merge_defaults(options)
15
+ fail "Client required" unless options[:client]
16
+ @options = DEFAULT_OPTIONS.merge(options)
17
+ @retry_count = (@options[:acquisition_timeout] / @options[:acquisition_delay].to_f).ceil
18
+ @client = @options[:client]
19
+ super()
13
20
  end
14
21
 
15
- def lock(key, resources = 1, options = {})
16
- options = self.class.merge_defaults(@options.merge(options))
17
- token = self.class.lock(key, resources, options)
22
+ def lock(key, resources = 1)
23
+ token = acquire_lock(key, resources)
18
24
 
19
- if token
25
+ if block_given? && token
20
26
  begin
21
- yield if block_given?
27
+ yield
22
28
  ensure
23
- self.class.unlock(key, token, options)
29
+ unlock(key, token)
24
30
  end
25
-
26
- true
27
31
  else
28
- false
32
+ token
29
33
  end
30
34
  end
31
35
 
32
36
  def locked?(key, resources = 1)
33
- self.class.locked?(key, resources, @options)
37
+ locks(key).size >= resources
34
38
  end
35
39
 
36
- class << self
37
- def lock(key, resources = 1, options = {})
38
- options = merge_defaults(options)
39
- acquisition_token = nil
40
- token = SecureRandom.base64(16)
41
-
42
- retry_with_timeout(key, options) do
43
- val, cas = get(key, options)
44
-
45
- if val.nil?
46
- set_initial(key, options)
47
- next
48
- end
40
+ def locks(key)
41
+ val, _ = get(key)
42
+ locks = deserialize_locks(val)
49
43
 
50
- locks = deserialize_and_clear_locks(val, options)
51
-
52
- if locks.size < resources
53
- add_lock(locks, token)
44
+ locks
45
+ end
54
46
 
55
- newval = serialize_locks(locks)
47
+ def refresh(key, acquisition_token)
48
+ retry_with_timeout(key) do
49
+ val, cas = get(key)
56
50
 
57
- if set(key, newval, cas, options)
58
- acquisition_token = token
59
- break
60
- end
61
- end
51
+ if val.nil?
52
+ set_initial(key)
53
+ next
62
54
  end
63
55
 
64
- acquisition_token
65
- end
66
-
67
- def locked?(key, resources = 1, options = {})
68
- locks(key, options).size >= resources
69
- end
56
+ locks = deserialize_and_clear_locks(val)
70
57
 
71
- def locks(key, options)
72
- options = merge_defaults(options)
73
- val, _ = get(key, options)
74
- locks = deserialize_locks(val)
58
+ refresh_lock(locks, acquisition_token)
75
59
 
76
- locks
60
+ break if set(key, serialize_locks(locks), cas)
77
61
  end
62
+ end
78
63
 
79
- def refresh(key, acquisition_token, options = {})
80
- options = merge_defaults(options)
64
+ def unlock(key, acquisition_token)
65
+ return unless acquisition_token
81
66
 
82
- retry_with_timeout(key, options) do
83
- val, cas = get(key, options)
67
+ retry_with_timeout(key) do
68
+ val, cas = get(key)
84
69
 
85
- if val.nil?
86
- set_initial(key, options)
87
- next
88
- end
70
+ break if val.nil?
89
71
 
90
- locks = deserialize_and_clear_locks(val, options)
72
+ locks = deserialize_and_clear_locks(val)
91
73
 
92
- refresh_lock(locks, acquisition_token)
74
+ acquisition_lock = remove_lock(locks, acquisition_token)
93
75
 
94
- break if set(key, serialize_locks(locks), cas, options)
95
- end
76
+ break unless acquisition_lock
77
+ break if set(key, serialize_locks(locks), cas)
96
78
  end
79
+ rescue LockClientError => _ # rubocop:disable Lint/HandleExceptions
80
+ # ignore - assume success due to optimistic locking
81
+ end
97
82
 
98
- def unlock(key, acquisition_token, options = {})
99
- options = merge_defaults(options)
100
-
101
- return unless acquisition_token
102
-
103
- retry_with_timeout(key, options) do
104
- val, cas = get(key, options)
83
+ def clear(key) # rubocop:disable Lint/UnusedMethodArgument
84
+ fail NotImplementedError
85
+ end
105
86
 
106
- break if val.nil?
87
+ private
107
88
 
108
- locks = deserialize_and_clear_locks(val, options)
89
+ def acquire_lock(key, resources = 1)
90
+ acquisition_token = nil
91
+ token = SecureRandom.base64(16)
109
92
 
110
- acquisition_lock = remove_lock(locks, acquisition_token)
93
+ retry_with_timeout(key) do
94
+ val, cas = get(key)
111
95
 
112
- break unless acquisition_lock
113
- break if set(key, serialize_locks(locks), cas, options)
96
+ if val.nil?
97
+ set_initial(key)
98
+ next
114
99
  end
115
- rescue LockClientError => _ # rubocop:disable Lint/HandleExceptions
116
- # ignore - assume success due to optimistic locking
117
- end
118
-
119
- def clear(key, options = {}) # rubocop:disable Lint/UnusedMethodArgument
120
- fail NotImplementedError
121
- end
122
100
 
123
- def merge_defaults(options = {})
124
- options = self::DEFAULT_OPTIONS.merge(options)
101
+ locks = deserialize_and_clear_locks(val)
125
102
 
126
- fail "Client required" unless options[:client]
103
+ if locks.size < resources
104
+ add_lock(locks, token)
127
105
 
128
- options
129
- end
130
-
131
- private
106
+ newval = serialize_locks(locks)
132
107
 
133
- def get(key, options) # rubocop:disable Lint/UnusedMethodArgument
134
- fail NotImplementedError
108
+ if set(key, newval, cas)
109
+ acquisition_token = token
110
+ break
111
+ end
112
+ end
135
113
  end
136
114
 
137
- def set(key, newval, oldval, options) # rubocop:disable Lint/UnusedMethodArgument
138
- fail NotImplementedError
139
- end
115
+ acquisition_token
116
+ end
140
117
 
141
- def set_initial(key, options) # rubocop:disable Lint/UnusedMethodArgument
142
- fail NotImplementedError
143
- end
118
+ def get(key) # rubocop:disable Lint/UnusedMethodArgument
119
+ fail NotImplementedError
120
+ end
144
121
 
145
- def synchronize(key, options)
146
- yield(key, options)
147
- end
122
+ def set(key, newval, oldval) # rubocop:disable Lint/UnusedMethodArgument
123
+ fail NotImplementedError
124
+ end
148
125
 
149
- def retry_with_timeout(key, options)
150
- count = (options[:retry_timeout] / options[:retry_delay].to_f).ceil
126
+ def set_initial(key) # rubocop:disable Lint/UnusedMethodArgument
127
+ fail NotImplementedError
128
+ end
151
129
 
152
- start = Time.now.to_f
130
+ def synchronize(key) # rubocop:disable Lint/UnusedMethodArgument
131
+ mon_synchronize { yield }
132
+ end
153
133
 
154
- count.times do
155
- now = Time.now.to_f
156
- break if now - start > options[:retry_timeout]
134
+ def retry_with_timeout(key)
135
+ start = Time.now.to_f
157
136
 
158
- synchronize(key, options) do
159
- yield
160
- end
137
+ @retry_count.times do
138
+ now = Time.now.to_f
139
+ break if now - start > @options[:acquisition_timeout]
161
140
 
162
- sleep(rand(options[:retry_delay] * 1000).to_f / 1000)
141
+ synchronize(key) do
142
+ yield
163
143
  end
164
- rescue => _
165
- raise LockClientError
166
- end
167
144
 
168
- def serialize_locks(locks)
169
- MessagePack.pack(locks.map { |time, token| [time.to_f, token] })
145
+ sleep(rand(@options[:acquisition_delay] * 1000).to_f / 1000)
170
146
  end
147
+ rescue => _
148
+ raise LockClientError
149
+ end
171
150
 
172
- def deserialize_and_clear_locks(val, options)
173
- clear_expired_locks(deserialize_locks(val), options)
174
- end
151
+ def serialize_locks(locks)
152
+ MessagePack.pack(locks.map { |time, token| [time.to_f, token] })
153
+ end
175
154
 
176
- def deserialize_locks(val)
177
- unpacked = (val.nil? || val == "") ? [] : MessagePack.unpack(val)
155
+ def deserialize_and_clear_locks(val)
156
+ clear_expired_locks(deserialize_locks(val))
157
+ end
178
158
 
179
- unpacked.map do |time, token|
180
- [Time.at(time), token]
181
- end
182
- rescue EOFError => _
183
- []
184
- end
159
+ def deserialize_locks(val)
160
+ unpacked = (val.nil? || val == "") ? [] : MessagePack.unpack(val)
185
161
 
186
- def clear_expired_locks(locks, options)
187
- expired = Time.now - options[:stale_lock_expiration]
188
- locks.reject { |time, _| time < expired }
162
+ unpacked.map do |time, token|
163
+ [Time.at(time), token]
189
164
  end
165
+ rescue EOFError => _
166
+ []
167
+ end
190
168
 
191
- def add_lock(locks, token)
192
- locks << [Time.now.to_f, token]
193
- end
169
+ def clear_expired_locks(locks)
170
+ expired = Time.now - @options[:stale_lock_expiration]
171
+ locks.reject { |time, _| time < expired }
172
+ end
194
173
 
195
- def remove_lock(locks, acquisition_token)
196
- lock = locks.find { |_, token| token == acquisition_token }
197
- locks.delete(lock)
198
- end
174
+ def add_lock(locks, token)
175
+ locks << [Time.now.to_f, token]
176
+ end
199
177
 
200
- def refresh_lock(locks, acquisition_token)
201
- remove_lock(locks, acquisition_token)
202
- add_lock(locks, token)
203
- end
178
+ def remove_lock(locks, acquisition_token)
179
+ lock = locks.find { |_, token| token == acquisition_token }
180
+ locks.delete(lock)
181
+ end
182
+
183
+ def refresh_lock(locks, acquisition_token)
184
+ remove_lock(locks, acquisition_token)
185
+ add_lock(locks, token)
204
186
  end
205
187
  end
206
188
  end
@@ -6,25 +6,22 @@ module Suo
6
6
  super
7
7
  end
8
8
 
9
- class << self
10
- def clear(key, options = {})
11
- options = merge_defaults(options)
12
- options[:client].delete(key)
13
- end
9
+ def clear(key)
10
+ @client.delete(key)
11
+ end
14
12
 
15
- private
13
+ private
16
14
 
17
- def get(key, options)
18
- options[:client].get_cas(key)
19
- end
15
+ def get(key)
16
+ @client.get_cas(key)
17
+ end
20
18
 
21
- def set(key, newval, cas, options)
22
- options[:client].set_cas(key, newval, cas)
23
- end
19
+ def set(key, newval, cas)
20
+ @client.set_cas(key, newval, cas)
21
+ end
24
22
 
25
- def set_initial(key, options)
26
- options[:client].set(key, "")
27
- end
23
+ def set_initial(key)
24
+ @client.set(key, "")
28
25
  end
29
26
  end
30
27
  end
@@ -6,37 +6,34 @@ module Suo
6
6
  super
7
7
  end
8
8
 
9
- class << self
10
- def clear(key, options = {})
11
- options = merge_defaults(options)
12
- options[:client].del(key)
13
- end
9
+ def clear(key)
10
+ @client.del(key)
11
+ end
14
12
 
15
- private
13
+ private
16
14
 
17
- def get(key, options)
18
- [options[:client].get(key), nil]
19
- end
20
-
21
- def set(key, newval, _, options)
22
- ret = options[:client].multi do |multi|
23
- multi.set(key, newval)
24
- end
15
+ def get(key)
16
+ [@client.get(key), nil]
17
+ end
25
18
 
26
- ret[0] == "OK"
19
+ def set(key, newval, _)
20
+ ret = @client.multi do |multi|
21
+ multi.set(key, newval)
27
22
  end
28
23
 
29
- def synchronize(key, options)
30
- options[:client].watch(key) do
31
- yield
32
- end
33
- ensure
34
- options[:client].unwatch
35
- end
24
+ ret[0] == "OK"
25
+ end
36
26
 
37
- def set_initial(key, options)
38
- options[:client].set(key, "")
27
+ def synchronize(key)
28
+ @client.watch(key) do
29
+ yield
39
30
  end
31
+ ensure
32
+ @client.unwatch
33
+ end
34
+
35
+ def set_initial(key)
36
+ @client.set(key, "")
40
37
  end
41
38
  end
42
39
  end
data/lib/suo/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Suo
2
- VERSION = "0.1.3"
2
+ VERSION = "0.2.0"
3
3
  end
data/test/client_test.rb CHANGED
@@ -3,62 +3,55 @@ require "test_helper"
3
3
  TEST_KEY = "suo_test_key".freeze
4
4
 
5
5
  module ClientTests
6
- def test_requires_client
7
- exception = assert_raises(RuntimeError) do
8
- @klass.lock(TEST_KEY, 1)
9
- end
10
-
11
- assert_equal "Client required", exception.message
12
- end
13
-
14
6
  def test_throws_failed_error_on_bad_client
15
7
  assert_raises(Suo::LockClientError) do
16
- @klass.lock(TEST_KEY, 1, client: {})
8
+ client = @client.class.new(client: {})
9
+ client.lock(TEST_KEY, 1)
17
10
  end
18
11
  end
19
12
 
20
13
  def test_class_single_resource_locking
21
- lock1 = @klass.lock(TEST_KEY, 1, client: @klass_client)
14
+ lock1 = @client.lock(TEST_KEY, 1)
22
15
  refute_nil lock1
23
16
 
24
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
17
+ locked = @client.locked?(TEST_KEY, 1)
25
18
  assert_equal true, locked
26
19
 
27
- lock2 = @klass.lock(TEST_KEY, 1, client: @klass_client)
20
+ lock2 = @client.lock(TEST_KEY, 1)
28
21
  assert_nil lock2
29
22
 
30
- @klass.unlock(TEST_KEY, lock1, client: @klass_client)
23
+ @client.unlock(TEST_KEY, lock1)
31
24
 
32
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
25
+ locked = @client.locked?(TEST_KEY, 1)
33
26
 
34
27
  assert_equal false, locked
35
28
  end
36
29
 
37
30
  def test_class_multiple_resource_locking
38
- lock1 = @klass.lock(TEST_KEY, 2, client: @klass_client)
31
+ lock1 = @client.lock(TEST_KEY, 2)
39
32
  refute_nil lock1
40
33
 
41
- locked = @klass.locked?(TEST_KEY, 2, client: @klass_client)
34
+ locked = @client.locked?(TEST_KEY, 2)
42
35
  assert_equal false, locked
43
36
 
44
- lock2 = @klass.lock(TEST_KEY, 2, client: @klass_client)
37
+ lock2 = @client.lock(TEST_KEY, 2)
45
38
  refute_nil lock2
46
39
 
47
- locked = @klass.locked?(TEST_KEY, 2, client: @klass_client)
40
+ locked = @client.locked?(TEST_KEY, 2)
48
41
  assert_equal true, locked
49
42
 
50
- @klass.unlock(TEST_KEY, lock1, client: @klass_client)
43
+ @client.unlock(TEST_KEY, lock1)
51
44
 
52
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
45
+ locked = @client.locked?(TEST_KEY, 1)
53
46
  assert_equal true, locked
54
47
 
55
- @klass.unlock(TEST_KEY, lock2, client: @klass_client)
48
+ @client.unlock(TEST_KEY, lock2)
56
49
 
57
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
50
+ locked = @client.locked?(TEST_KEY, 1)
58
51
  assert_equal false, locked
59
52
  end
60
53
 
61
- def test_instance_single_resource_locking
54
+ def test_block_single_resource_locking
62
55
  locked = false
63
56
 
64
57
  @client.lock(TEST_KEY, 1) { locked = true }
@@ -66,22 +59,45 @@ module ClientTests
66
59
  assert_equal true, locked
67
60
  end
68
61
 
69
- def test_instance_unlocks_on_exception
62
+ def test_block_unlocks_on_exception
70
63
  assert_raises(RuntimeError) do
71
64
  @client.lock(TEST_KEY, 1) { fail "Test" }
72
65
  end
73
66
 
74
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
67
+ locked = @client.locked?(TEST_KEY, 1)
75
68
  assert_equal false, locked
76
69
  end
77
70
 
71
+ def test_readme_example
72
+ output = Queue.new
73
+ threads = []
74
+
75
+ threads << Thread.new { @client.lock(TEST_KEY, 2) { output << "One"; sleep 2 } }
76
+ threads << Thread.new { @client.lock(TEST_KEY, 2) { output << "Two"; sleep 2 } }
77
+ threads << Thread.new { @client.lock(TEST_KEY, 2) { output << "Three" } }
78
+
79
+ threads.map(&:join)
80
+
81
+ ret = []
82
+
83
+ ret << output.pop
84
+ ret << output.pop
85
+
86
+ ret.sort!
87
+
88
+ assert_equal 0, output.size
89
+ assert_equal ["One", "Two"], ret
90
+ end
91
+
78
92
  def test_instance_multiple_resource_locking
79
93
  success_counter = Queue.new
80
94
  failure_counter = Queue.new
81
95
 
82
- 50.times.map do |i|
96
+ client = @client.class.new(acquisition_timeout: 0.9, client: @client.client)
97
+
98
+ 100.times.map do |i|
83
99
  Thread.new do
84
- success = @client.lock(TEST_KEY, 25, retry_timeout: 0.9) do
100
+ success = @client.lock(TEST_KEY, 50) do
85
101
  sleep(3)
86
102
  success_counter << i
87
103
  end
@@ -90,17 +106,19 @@ module ClientTests
90
106
  end
91
107
  end.map(&:join)
92
108
 
93
- assert_equal 25, success_counter.size
94
- assert_equal 25, failure_counter.size
109
+ assert_equal 50, success_counter.size
110
+ assert_equal 50, failure_counter.size
95
111
  end
96
112
 
97
113
  def test_instance_multiple_resource_locking_longer_timeout
98
114
  success_counter = Queue.new
99
115
  failure_counter = Queue.new
100
116
 
101
- 50.times.map do |i|
117
+ client = @client.class.new(acquisition_timeout: 3, client: @client.client)
118
+
119
+ 100.times.map do |i|
102
120
  Thread.new do
103
- success = @client.lock(TEST_KEY, 25, retry_timeout: 2) do
121
+ success = client.lock(TEST_KEY, 50) do
104
122
  sleep(0.5)
105
123
  success_counter << i
106
124
  end
@@ -109,19 +127,19 @@ module ClientTests
109
127
  end
110
128
  end.map(&:join)
111
129
 
112
- assert_equal 50, success_counter.size
130
+ assert_equal 100, success_counter.size
113
131
  assert_equal 0, failure_counter.size
114
132
  end
115
133
  end
116
134
 
117
135
  class TestBaseClient < Minitest::Test
118
136
  def setup
119
- @klass = Suo::Client::Base
137
+ @client = Suo::Client::Base.new(client: {})
120
138
  end
121
139
 
122
140
  def test_not_implemented
123
141
  assert_raises(NotImplementedError) do
124
- @klass.send(:get, TEST_KEY, {})
142
+ @client.send(:get, TEST_KEY)
125
143
  end
126
144
  end
127
145
  end
@@ -130,13 +148,12 @@ class TestMemcachedClient < Minitest::Test
130
148
  include ClientTests
131
149
 
132
150
  def setup
133
- @klass = Suo::Client::Memcached
134
- @client = @klass.new
135
- @klass_client = Dalli::Client.new("127.0.0.1:11211")
151
+ @dalli = Dalli::Client.new("127.0.0.1:11211")
152
+ @client = Suo::Client::Memcached.new
136
153
  end
137
154
 
138
155
  def teardown
139
- @klass_client.delete(TEST_KEY)
156
+ @dalli.delete(TEST_KEY)
140
157
  end
141
158
  end
142
159
 
@@ -144,13 +161,12 @@ class TestRedisClient < Minitest::Test
144
161
  include ClientTests
145
162
 
146
163
  def setup
147
- @klass = Suo::Client::Redis
148
- @client = @klass.new
149
- @klass_client = Redis.new
164
+ @redis = Redis.new
165
+ @client = Suo::Client::Redis.new
150
166
  end
151
167
 
152
168
  def teardown
153
- @klass_client.del(TEST_KEY)
169
+ @redis.del(TEST_KEY)
154
170
  end
155
171
  end
156
172
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: suo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Elser