redstruct 0.1.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
+ SHA1:
3
+ metadata.gz: f2a542b5597811e35ed17b643ae0df3fb01fb226
4
+ data.tar.gz: bc58fb20c1cc5d2b4a502c3ba085f2d2d1304056
5
+ SHA512:
6
+ metadata.gz: 8a98538b8e61c874e1b42d487d46a83176cb563ede67eefa78131e577f6a16627678658fd314e11a7981c0c6f7ab0e9022ca82c61e6723e4620c0a3bb099fa1b
7
+ data.tar.gz: 7a230c6f9cc89433e0580efa59ddfd716bdaf569ae2f2f20c53dc2b1909d1d8f332f2b789870a834f90a9fbcc5953cc275639478ff842b40368fa41608da7add
data/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Redstruct
2
+
3
+ Provides higher level data structures in Ruby using standard Redis commands. Also provides basic object mapping for pre-existing types.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'redstruct'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install redstruct
20
+
21
+ ## Usage
22
+
23
+ TODO: Write usage instructions here
24
+
25
+ ## Development
26
+
27
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
28
+
29
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
30
+
31
+ *Note*
32
+
33
+ Avoid using transactions; the Redis documentation suggests using Lua scripts where possible, as in most cases they will be faster than transactions. Use the `Redstruct::Utils::Scriptable` module and the `defscript` macro instead.
34
+
35
+ ## Contributing
36
+
37
+ Bug reports and pull requests are welcome on GitHub at https://github.com/npepinpe/redstruct.
38
+
39
+
40
+ ## License
41
+
42
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Bundler.require(:default, :development)
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb']
10
+ end
11
+
12
+ task :default => :test
13
+
14
+ require 'redstruct/yard/defscript_handler'
15
+ YARD::Rake::YardocTask.new do |t|
16
+ t.files = ['lib/**/*.rb']
17
+ t.options = ['--output-dir=./docs']
18
+ end
@@ -0,0 +1,14 @@
1
+ module Redstruct
2
+ class Configuration
3
+ # @return [ConnectionPool] The Redis-rb connection pool to use
4
+ attr_accessor :connection_pool
5
+
6
+ # @return [::String] Default namespace for factories
7
+ attr_accessor :namespace
8
+
9
+ def initialize
10
+ @connection_pool = nil
11
+ @namespace = nil
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ module Redstruct
2
+ class Connection
3
+ # @return [Array<Symbol>] List of methods from the Redis class that we don't want to delegate to.
4
+ NON_COMMAND_METHODS = [:[], :[]=, :_eval, :_scan, :method_missing, :call, :dup, :inspect, :to_s].freeze
5
+
6
+ attr_reader :pool
7
+
8
+ def initialize(pool)
9
+ raise(Redstruct::Error, 'Requires a ConnectionPool to proxy to') unless pool.is_a?(ConnectionPool)
10
+ @pool = pool
11
+ end
12
+
13
+ # While slower on load, defining all methods that we want to pipe to one of the connections results in
14
+ # faster calls at runtime, and gives us the convenience of not going through the pool.with everytime.
15
+ Redis.public_instance_methods(false).each do |method|
16
+ next if NON_COMMAND_METHODS.include?(method)
17
+ class_eval <<~METHOD, __FILE__, __LINE__ + 1
18
+ def #{method}(*args)
19
+ connection = Thread.current[:__redstruct_connection]
20
+ if connection.nil?
21
+ return @pool.with { |c| c.#{method}(*args) }
22
+ else
23
+ return connection.#{method}(*args)
24
+ end
25
+ end
26
+ METHOD
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ module Redstruct
2
+ # Used to provide a single base error type to filter all errors coming from the gem.
3
+ class Error < StandardError
4
+ end
5
+ end
@@ -0,0 +1,95 @@
1
+ module Redstruct
2
+ class Factory
3
+ # Module to hold all the factory creation methods.
4
+ module Creation
5
+ # Builds a struct with the given key (namespaced) and sharing the factory connection
6
+ # Building a struct is only really useful if you plan on making only basic operations,
7
+ # such as delete, expire, etc. It is however recommended to always build your objects
8
+ # in the same way, e.g. if it's a lock, use Factory#lock
9
+ # @param [::String] key base key to use
10
+ # @return [Redstruct::Types::Struct] base struct pointing to that key
11
+ def struct(key, **options)
12
+ return create(Redstruct::Types::Struct, key, **options)
13
+ end
14
+
15
+ # Builds a Redis string struct from the key
16
+ # @param [::String] key base key to use
17
+ # @return [Redstruct::Types::String]
18
+ def string(key, **options)
19
+ return create(Redstruct::Types::String, key, **options)
20
+ end
21
+
22
+ # Builds a Redis list struct from the key
23
+ # @param [::String] key base key to use
24
+ # @return [Redstruct::Types::List]
25
+ def list(key, **options)
26
+ return create(Redstruct::Types::List, key, **options)
27
+ end
28
+
29
+ # Builds a Redis set struct from the key
30
+ # @param [::String] key base key to use
31
+ # @return [Redstruct::Types::Set]
32
+ def set(key, **options)
33
+ return create(Redstruct::Types::Set, key, **options)
34
+ end
35
+
36
+ # Builds a Redis sorted set (zset) struct from the key
37
+ # @param [::String] key base key to use
38
+ # @return [Redstruct::Types::SortedSet]
39
+ def sorted_set(key, **options)
40
+ return create(Redstruct::Types::SortedSet, key, **options)
41
+ end
42
+
43
+ # Builds a Redis hash struct from the key
44
+ # @param [::String] key base key to use
45
+ # @return [Redstruct::Types::Hash]
46
+ def hash(key, **options)
47
+ return create(Redstruct::Types::Hash, key, **options)
48
+ end
49
+
50
+ # Builds a Redis backed lock from the key
51
+ # @param [::String] key base key to use
52
+ # @return [Redstruct::Types::Lock]
53
+ def lock(key, **options)
54
+ return create(Redstruct::Types::Lock, key, **options)
55
+ end
56
+
57
+ # Builds a Redis counter struct from the key
58
+ # @param [::String] key base key to use
59
+ # @return [Redstruct::Types::Counter]
60
+ def counter(key, **options)
61
+ return create(Redstruct::Types::Counter, key, **options)
62
+ end
63
+
64
+ # Builds a Redis backed queue from the key
65
+ # @param [::String] key base key to use
66
+ # @return [Redstruct::Types::Queue]
67
+ def queue(key)
68
+ return create(Redstruct::Types::Queue, key)
69
+ end
70
+
71
+ # @todo The script cache is actually based on the database you will connect to. Therefore, it might be smarter to move it to the connection used?
72
+ # Caveat: if the script with the given ID exists in the cache, we don't bother updating it.
73
+ # So if the script actually changed since the first call, the one sent during the first call will
74
+ def script(id, script)
75
+ return @script_cache.synchronize do
76
+ @script_cache[id] = Redstruct::Types::Script.new(key: id, script: script, factory: self) if @script_cache[id].nil?
77
+ @script_cache[id]
78
+ end
79
+ end
80
+
81
+ # Returns a factory with an isolated namespace.
82
+ # @example Given a factory `f` with namespace fact:first
83
+ # f.factory('second') # => Redstruct::Factory: namespace: <"fact:first:second">, script_cache: <[]>
84
+ # @return [Factory] namespaced factory
85
+ def factory(namespace)
86
+ return self.class.new(connection: @connection, namespace: isolate(namespace))
87
+ end
88
+
89
+ def create(type, key, **options)
90
+ return type.new(key: isolate(key), factory: self, **options)
91
+ end
92
+ private :create
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,7 @@
1
+ module Redstruct
2
+ class Factory
3
+ # Module to hold all the factory creation methods.
4
+ module Deserialization
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,45 @@
1
+ module Redstruct
2
+ # Main interface of the gem; this class should be used to build all Redstruct
3
+ # objects, even when deserializing them.
4
+ class Factory
5
+ include Redstruct::Utils::Inspectable, Redstruct::Factory::Creation
6
+ extend Redstruct::Factory::Deserialization
7
+
8
+ # @return [Connection] The connection proxy to use when executing commands. Shared by all factory produced objects.
9
+ attr_reader :connection
10
+
11
+ # @param [Redstruct::Connection] connection connection to use for all objects built by the factory
12
+ # @param [ConnectionPool] pool pool to use to build a connection from if no connection param given
13
+ # @param [::String] namespace all objects build from the factory will have their keys namespaced under this one
14
+ # @return [Factory]
15
+ def initialize(connection: nil, pool: nil, namespace: nil)
16
+ namespace ||= Redstruct.config.namespace
17
+
18
+ if connection.nil?
19
+ pool ||= Redstruct.config.connection_pool
20
+ raise(Redstruct::Error, 'A connection pool is required to create a factory, but none was given') if pool.nil?
21
+ connection = Redstruct::Connection.new(pool)
22
+ end
23
+
24
+ @connection = connection
25
+ @namespace = namespace
26
+ @script_cache = {}.tap { |hash| hash.extend(MonitorMixin) }
27
+ end
28
+
29
+ # Returns a namespaced version of the key (unless already namespaced)
30
+ # @param [String] key the key to isolate/namespace
31
+ # @return [String] namespaced version of the key (or the key itself if already namespaced)
32
+ def isolate(key)
33
+ return @namespace.nil? || key.start_with?(@namespace) ? key : "#{@namespace}:#{key}"
34
+ end
35
+
36
+ # :nocov:
37
+
38
+ # Helper method for serialization
39
+ def inspectable_attributes
40
+ return { namespace: @namespace, script_cache: @script_cache.keys }
41
+ end
42
+
43
+ # :nocov:
44
+ end
45
+ end
@@ -0,0 +1,175 @@
1
+ require 'securerandom'
2
+
3
+ module Redstruct
4
+ module Hls
5
+ # Implementation of a simple binary lock (locked/not locked), with option to block and wait for the lock.
6
+ # Uses two redis structures: a string for the lease, and a list for blocking operations.
7
+ # @see #acquire
8
+ # @see #release
9
+ # @see #locked
10
+ # @attr_reader [::String, nil] token the current token or nil
11
+ # @attr_reader [Fixnum] expiry expiry of the underlying redis structures in milliseconds
12
+ # @attr_reader [Fixnum, nil] timeout the timeout to wait when attempting to acquire the lock, in seconds
13
+ class Lock < Redstruct::Types::Base
14
+ include Redstruct::Utils::Scriptable, Redstruct::Utils::Coercion
15
+
16
+ # The default expiry on the underlying redis keys, in milliseconds
17
+ DEFAULT_EXPIRY = 1000
18
+
19
+ # The default timeout when blocking, in seconds; a nil value means it is non-blocking
20
+ DEFAULT_TIMEOUT = nil
21
+
22
+ attr_reader :token, :expiry, :timeout
23
+
24
+ # @param [Integer] expiry in milliseconds; to prevent infinite locking, each mutex is released after a certain expiry time
25
+ # @param [Integer] timeout in seconds; if > 0, will block for this amount of time when trying to obtain the lock
26
+ def initialize(expiry: DEFAULT_EXPIRY, timeout: DEFAULT_TIMEOUT, **options)
27
+ super(**options)
28
+
29
+ @token = nil
30
+ @expiry = expiry
31
+ @timeout = timeout.to_i
32
+
33
+ create do |factory|
34
+ @lease = factory.string('lease')
35
+ @tokens = factory.list('tokens')
36
+ end
37
+ end
38
+
39
+ # Executes the given block if the lock can be acquired
40
+ # @yield Block to be executed if the lock is acquired
41
+ def locked
42
+ yield if acquire
43
+ ensure
44
+ release
45
+ end
46
+
47
+ # Whether or not the lock will block when attempting to acquire it
48
+ # @return [Boolean]
49
+ def blocking?
50
+ return @timeout.positive?
51
+ end
52
+
53
+ # Attempts to acquire the lock. First attempts to grab the lease (a redis string).
54
+ # If the current token is already the lease token, the lock is considered acquired.
55
+ # If there is no current lease, then sets it to the current token.
56
+ # If there is a current lease that is not the current token, then:
57
+ # 1) If this not a blocking lock (see Lock#blocking?), return false
58
+ # 2) If this is a blocking lock, block and wait for the next token to be pushed on the tokens list
59
+ # 3) If a token was pushed, set it as our token and refresh the expiry
60
+ # @return [Boolean] True if acquired, false otherwise
61
+ def acquire
62
+ acquired = false
63
+ token = non_blocking_acquire(@token)
64
+ token = blocking_acquire if token.nil? && blocking?
65
+
66
+ unless token.nil?
67
+ @token = token
68
+ acquired = true
69
+ end
70
+
71
+ return acquired
72
+ end
73
+
74
+ # Releases the lock only if the current token is the value of the lease.
75
+ # If the lock is a blocking lock (see Lock#blocking?), push the next token on the tokens list.
76
+ # @return [Boolean] True if released, false otherwise
77
+ def release
78
+ return false if @token.nil?
79
+
80
+ next_token = SecureRandom.uuid
81
+ return coerce_bool(release_script(keys: [@lease.key, @tokens.key], argv: [@token, next_token, @expiry]))
82
+ end
83
+
84
+ def non_blocking_acquire(token = nil)
85
+ token ||= generate_token
86
+ return acquire_script(keys: @lease.key, argv: [token, @expiry])
87
+ end
88
+ private :non_blocking_acquire
89
+
90
+ def blocking_acquire
91
+ timeout = @timeout == Float::INFINITY ? 0 : @timeout
92
+ token = @tokens.pop(timeout: timeout)
93
+
94
+ # Attempt to reacquire in a non blocking way to:
95
+ # 1) assert we do own the lock (edge case)
96
+ # 2) touch the lock expiry
97
+ token = non_blocking_acquire(token) unless token.nil?
98
+
99
+ return token
100
+ end
101
+ private :blocking_acquire
102
+
103
+ # The acquire script attempts to set the lease (keys[1]) to the given token (argv[1]), only
104
+ # if it wasn't already set. It then compares to check if the value of the lease is that of the token,
105
+ # and if so refreshes the expiry (argv[2]) time of the lease.
106
+ # @param [Array<(::String)>] keys The lease key specifying who owns the mutex at the moment
107
+ # @param [Array<(::String, Fixnum)>] argv The current token; the expiry time in milliseconds
108
+ # @return [::String] Returns the token if acquired, nil otherwise.
109
+ defscript :acquire_script, <<~LUA
110
+ local token = ARGV[1]
111
+ local expiry = tonumber(ARGV[2])
112
+
113
+ redis.call('set', KEYS[1], token, 'NX')
114
+ if redis.call('get', KEYS[1]) == token then
115
+ redis.call('pexpire', KEYS[1], expiry)
116
+ return token
117
+ end
118
+
119
+ return false
120
+ LUA
121
+
122
+ # The release script compares the given token (argv[1]) with the lease value (keys[1]); if they are the same,
123
+ # then a new token (argv[2]) is set as the lease, and pushed on the tokens (keys[2]) list
124
+ # for the next acquire request.
125
+ # @param [Array<(::String, ::String)>] keys The lease key; the tokens list key
126
+ # @param [Array<(::String, ::String, Fixnum)>] argv The current token; the next token to push; the expiry time of both keys
127
+ # @return [Fixnum] 1 if released, 0 otherwise
128
+ defscript :release_script, <<~LUA
129
+ local currentToken = ARGV[1]
130
+ local nextToken = ARGV[2]
131
+ local expiry = tonumber(ARGV[3])
132
+
133
+ if redis.call('get', KEYS[1]) == currentToken then
134
+ redis.call('set', KEYS[1], nextToken, 'PX', expiry)
135
+ redis.call('lpush', KEYS[2], nextToken)
136
+ redis.call('pexpire', KEYS[2], expiry)
137
+ return true
138
+ end
139
+
140
+ return false
141
+ LUA
142
+
143
+ # @!group Serialization
144
+ # Returns a hash representation of the object
145
+ # @see Lock#from_h
146
+ # @return [Hash<Symbol, Object>] hash representation of the lock
147
+ def to_h
148
+ return super.merge(token: @token, expiry: @expiry, timeout: @timeout)
149
+ end
150
+
151
+ class << self
152
+ # Builds a lock from a hash.
153
+ # @see Lock#to_h
154
+ # @see Factory#create_from_h
155
+ # @param [Hash] hash hash generated by calling Lock#to_h. Ensure beforehand that keys are symbols.
156
+ # @return [Lock]
157
+ def from_h(hash, factory)
158
+ hash[:factory] = factory
159
+ return new(**hash)
160
+ end
161
+ end
162
+ # @!endgroup
163
+
164
+ def generate_token
165
+ return SecureRandom.uuid
166
+ end
167
+ private :generate_token
168
+
169
+ # Helper method for easy inspection
170
+ def inspectable_attributes
171
+ super.merge(expiry: @expiry, blocking: blocking?)
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,29 @@
1
+ module Redstruct
2
+ module Hls
3
+ class Queue < Redstruct::Types::List
4
+ include Redstruct::Utils::Scriptable
5
+
6
+ def enqueue(*elements)
7
+ self.connection.rpush(@key, elements)
8
+ end
9
+
10
+ def dequeue(length: 1)
11
+ elements = dequeue_script(keys: @key, argv: length)
12
+ length == 1 ? elements.first : elements
13
+ end
14
+
15
+ # Dequeues up to argv[1] amount of items from the list at keys[1]
16
+ # @param [Array<(::String)>] keys The key of the list to dequeue from
17
+ # @param [Array<(Fixnum)>] argv The number of items to dequeue
18
+ # @return [Array] An array of items dequeued or an empty array
19
+ defscript :dequeue_script, <<~LUA
20
+ local length = tonumber(ARGV[1])
21
+ local elements = redis.call('lrange', KEYS[1], 0, length - 1)
22
+ redis.call('ltrim', KEYS[1], length, -1)
23
+
24
+ return elements
25
+ LUA
26
+ protected :dequeue_script
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,2 @@
1
+ require 'redstruct/hls/lock'
2
+ require 'redstruct/hls/queue'
@@ -0,0 +1,47 @@
1
+ module Redstruct
2
+ module Types
3
+ # Base class for all objects a factory can produce
4
+ class Base
5
+ include Redstruct::Utils::Inspectable
6
+ extend Forwardable
7
+
8
+ def_delegators :@factory, :connection, :connection
9
+
10
+ # @return [String] The key used to identify the struct on redis
11
+ attr_reader :key
12
+
13
+ def initialize(key:, factory:)
14
+ @factory = factory
15
+ @key = key
16
+ end
17
+
18
+ def with
19
+ self.connection.pool.with do |c|
20
+ begin
21
+ Thread.current[:__redstruct_connection] = c
22
+ yield(c)
23
+ ensure
24
+ Thread.current[:__redstruct_connection] = nil
25
+ end
26
+ end
27
+ end
28
+
29
+ def to_h
30
+ return { key: @key }
31
+ end
32
+
33
+ def create
34
+ return unless block_given?
35
+ subfactory = @factory.factory(@key)
36
+ yield(subfactory)
37
+ end
38
+ protected :create
39
+
40
+ # :nocov:
41
+ def inspectable_attributes
42
+ { key: @key, factory: @factory }
43
+ end
44
+ # :nocov:
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,65 @@
1
+ module Redstruct
2
+ module Types
3
+ class Counter < Redstruct::Types::String
4
+ include Redstruct::Utils::Scriptable
5
+
6
+ def initialize(increment: 1, max: nil, **options)
7
+ super(**options)
8
+ @increment = increment
9
+ @max = max
10
+ end
11
+
12
+ def get
13
+ super.to_i
14
+ end
15
+
16
+ def set(value)
17
+ super(value.to_i)
18
+ end
19
+
20
+ def increment(by: nil, max: nil)
21
+ by ||= @increment
22
+ max ||= @max
23
+
24
+ value = if max.nil?
25
+ self.connection.incrby(@key, by.to_i).to_i
26
+ else
27
+ ring_increment_script(keys: @key, argv: [by.to_i, max.to_i]).to_i
28
+ end
29
+
30
+ return value
31
+ end
32
+
33
+ def decrement(by: nil, max: nil)
34
+ by ||= @increment
35
+ by = -by.to_i
36
+ return increment(by: by, max: max)
37
+ end
38
+
39
+ def getset(value)
40
+ return super(value.to_i).to_i
41
+ end
42
+
43
+ # @!group Lua Scripts
44
+
45
+ defscript :ring_increment_script, <<~LUA
46
+ local by = tonumber(ARGV[1])
47
+ local max = tonumber(ARGV[2])
48
+ local current = redis.call('get', KEYS[1])
49
+ local value = current and tonumber(current) or 0
50
+
51
+ value = (value + by) % max
52
+ redis.call('set', KEYS[1], value)
53
+
54
+ return value
55
+ LUA
56
+
57
+ # @!endgroup
58
+
59
+ # Helper method for easy inspection
60
+ def inspectable_attributes
61
+ super.merge(max: @max, increment: @increment)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,72 @@
1
+ module Redstruct
2
+ module Types
3
+ class Hash < Redstruct::Types::Struct
4
+ include Redstruct::Utils::Coercion
5
+
6
+ def [](key)
7
+ return self.connection.hget(@key, key)
8
+ end
9
+
10
+ def []=(key, value)
11
+ self.connection.hset(@key, key, value)
12
+ end
13
+
14
+ def set(key, value, overwrite: true)
15
+ if overwrite
16
+ self[key] = value
17
+ else
18
+ self.connection.hsetnx(@key, key, value)
19
+ end
20
+ end
21
+
22
+ def get(*keys)
23
+ return self[keys.first] if keys.size == 1
24
+ return self.connection.mapped_hmget(@key, *keys)
25
+ end
26
+
27
+ def update(hash)
28
+ self.connection.mapped_hmset(@key, hash)
29
+ end
30
+
31
+ def delete(*keys)
32
+ return self.connection.hdel(@key, keys)
33
+ end
34
+
35
+ def key?(key)
36
+ return coerce_bool(self.connection.hexists(@key, key))
37
+ end
38
+
39
+ def incr(key, increment: 1)
40
+ if increment.is_a?(Float)
41
+ self.connection.hincrbyfloat(@key, key, increment.to_f)
42
+ else
43
+ self.connection.hincrby(@key, key, increment)
44
+ end
45
+ end
46
+
47
+ def decr(key, increment: 1)
48
+ return incr(key, (-increment))
49
+ end
50
+
51
+ def to_h
52
+ return self.connection.hgetall(@key)
53
+ end
54
+
55
+ def keys
56
+ return self.connection.hkeys(@key)
57
+ end
58
+
59
+ def values
60
+ return self.connection.hvals(@key)
61
+ end
62
+
63
+ def size
64
+ return self.connection.hlen(@key)
65
+ end
66
+
67
+ def each(options = {}, &block)
68
+ return self.connection.hscan_each(@key, options, &block)
69
+ end
70
+ end
71
+ end
72
+ end