attr_memoized 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,10 @@
1
+ sudo: false
2
+ language: ruby
3
+ cache: bundler
4
+ rvm:
5
+ - jruby-9.1.9.0
6
+ - 2.3.4
7
+ - 2.4.1
8
+ before_install: gem install bundler -v 1.15.1
9
+ after_success:
10
+ - bundle exec codeclimate-test-reporter
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in attr_memoized.gemspec
4
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Konstantin Gredeskoul
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,170 @@
1
+ [![Build Status](https://travis-ci.org/kigster/attr_memoized.svg?branch=master)](https://travis-ci.org/kigster/attr_memoized)
2
+ [![Code Climate](https://codeclimate.com/github/kigster/attr_memoized/badges/gpa.svg)](https://codeclimate.com/github/kigster/attr_memoized)
3
+ [![Test Coverage](https://codeclimate.com/github/kigster/attr_memoized/badges/coverage.svg)](https://codeclimate.com/github/kigster/attr_memoized/coverage)
4
+ [![Issue Count](https://codeclimate.com/github/kigster/attr_memoized/badges/issue_count.svg)](https://codeclimate.com/github/kigster/attr_memoized)
5
+
6
+ # AttrMemoized
7
+
8
+ This is a simple, and yet rather useful **memoization** library, with a specific goal of being **thread-safe** during lazy-loading of attributes. Class method `attr_memoized` automatically generates attribute reader and attribute writer methods. The reader performs a thread-safe lazy-initialization of each attribute. The writer performs a thread-safe assignment. You can disable writer method generation by passing `writer: false` option to `attr_memoized` method.
9
+
10
+ Any `attr_memoized` attribute may depend on any number of regular attributes or other `attr_memoized` attributes.
11
+
12
+ This gems provides a shorthand syntax for defining lazy-initialized variables as "one-liners", while additionally providing thread-safety guarantees around lazy-initilization of attributes, or attribute assignments.
13
+
14
+ #### Caveat
15
+
16
+ Note, that if the initialization or assignment returns a "falsey" result (ie, `false` or `nil`), then the attribute will attempt to be re-initialized every time its "reader" method is called. This is not a bug. We treat falsey value as uninitialized by design.
17
+
18
+ ## Complete Example
19
+
20
+ Below we have a `Configuration` class that has several attributes that are all lazy loaded.
21
+
22
+ ```ruby
23
+ require 'redis'
24
+ # Save config to Redis
25
+ r = Redis.new
26
+ #=> #<Redis:0x007fbd8d3a4308>
27
+ r.set('config_file', '{ "host": "127.0.0.1" }')
28
+ #=> OK
29
+ r.set('another_file', '{ "host": "google.com" }')
30
+ #=> OK
31
+ r.get('config_file') #=> { "host": "127.0.0.1" }
32
+
33
+ require 'attr_memoized'
34
+ module Concurrent
35
+ class RedisConfig
36
+ include AttrMemoized
37
+ # Now there is an instance and a class methods +#mutex+ are defined.
38
+ # We also have an instance method +with_lock+, and a class method
39
+ # +attr_memoized+
40
+ attr_memoized :contents, -> { redis.get(redis_key) }
41
+ attr_memoized :redis, -> { Redis.new }
42
+ attr_memoized :redis_key, -> { 'config_file' }
43
+
44
+ def reload_config!(new_key)
45
+ # +with_lock+ method if offered in place of +synchronize+
46
+ # to avoid double-locking within the same thread.
47
+ with_lock do
48
+ self.redis_key = new_key
49
+ contents(reload: true)
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ @config = Concurrent::RedisConfig.new
56
+ @config.contents
57
+ #=> { "host": "127.0.0.1" }
58
+ @config.reload_config!('another_file')
59
+ #=> { "host": "google.com" }
60
+ @config.contents
61
+ #=> { "host": "google.com" }
62
+ ```
63
+
64
+ ### The Problem
65
+
66
+ One of the issues with memoization in multi-threaded environment is that it may lead to unexpected or undefined behavior, due to the situation known as a [_race condition_](https://stackoverflow.com/questions/34510/what-is-a-race-condition).
67
+
68
+ Consider the following example:
69
+
70
+ ```ruby
71
+ class Account
72
+ def self.owner
73
+ # Slow expensive query
74
+ @owner ||= ActiveRecord::Base.execute('select ...').first
75
+ end
76
+ end
77
+ # Let's be dangerous:
78
+ [ Thread.new { Account.owner },
79
+ Thread.new { Account.owner } ].map(&:join)
80
+ ```
81
+
82
+ Ruby evaluates `a||=b` as `a || a=b`, which means that the assignment above won't happen if `a` is "falsey", ie. `false` or `nil`. If the method `self.owner` is not synchronized, then both threads will execute the expensive query, and only the result of the query executed by the second thread will be saved in `@owner`, even though by that time it will already have a value assigned by the first thread, that by that time had already completed.
83
+
84
+ Most memoization gems out there, among those that the author had reviewed, did not seem to be concerned with thread safety, which is actually OK under wide ranging situations, particularly if the objects are not meant to be shared across threads.
85
+
86
+ But in multi-threaded applications it's important to protect initializers of expensive resources, which is exactly what this library attempts to accomplish.
87
+
88
+
89
+ ## Usage
90
+
91
+ `AttrMemoized` — the gem's primary module, when included, decorates the receiver with several useful
92
+ methods:
93
+
94
+ * Pre-initialized class method `#mutex`. Each class that includes `AttrMemoized` gets their own mutex.
95
+
96
+ * Pre-initialized instance method `#mutex`. Each instance of the class gets it's own dedicated mutex.
97
+
98
+ * Convenience method `#with_lock` is provided in place of `#mutex.synhronize` and should be used to wrap any state changes to the class in order to guard against concurrent modification by other threads. It will only use `mutex.synchronize` once per thread, to avoid self-deadlocking.
99
+
100
+ * New class method `#attr_memoized` is added, with the following syntax:
101
+
102
+ ```ruby
103
+ attr_memoized :attribute, [ :attribute, ...], -> { block returning a value } # Proc
104
+ attr_memoized :attribute, [ :attribute, ...], :instance_method # symbol
105
+ attr_memoized :attribute, [ :attribute, ...], SomeClass.method(:method_name) # Method instance
106
+ ```
107
+
108
+ * In the above definitions:
109
+ * If a `Proc` is provided as an initializer, it will be called via `#instance_exec` method on the instance and, therefore, can access any public or private method of the instance without the need for `self.` receiver.
110
+
111
+ * If the initializer is a `Symbol`, it is expected to be an instance method name, of a method that takes no arguments.
112
+
113
+ * Finally, any `Method` instance can also be used.
114
+
115
+ * Note, that multiple attribute names can be passed to `#attr_memoized`, and they will be lazy-loaded in the order of access and independently of each other. If the block always returns the same exactly value, then the list may be viewed as aliases. But if the block returns a new value each time its called, then each attribute will be initialized with a different value, eg:
116
+
117
+ ```ruby
118
+ srand
119
+ require 'attr_memoized'
120
+ class RandomNumberGenerator
121
+ include AttrMemoized
122
+ attr_memoized :random1,
123
+ :random2,
124
+ :random3, -> { rand(2**64) }
125
+ end
126
+
127
+ rng = RandomNumberGenerator.new
128
+ # each is initialized as it's called, and so they
129
+ # are all different:
130
+ rng.random1 #=> 1304594275874777789
131
+ rng.random2 #=> 12671375021040220422
132
+ rng.random3 #=> 16656281832060271071
133
+
134
+ # second time, they are all already memoized:
135
+ rng.random1 #=> 1304594275874777789
136
+ rng.random2 #=> 12671375021040220422
137
+ rng.random3 #=> 16656281832060271071
138
+ ```
139
+
140
+
141
+ ## Installation
142
+
143
+ Add this line to your application's Gemfile:
144
+
145
+ ```ruby
146
+ gem 'attr_memoized'
147
+ ```
148
+
149
+ And then execute:
150
+
151
+ $ bundle
152
+
153
+ Or install it yourself as:
154
+
155
+ $ gem install attr_memoized
156
+
157
+
158
+ ## Development
159
+
160
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
161
+
162
+ 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).
163
+
164
+ ## Contributing
165
+
166
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/kigster/attr_memoized](https://github.com/kigster/attr_memoized).
167
+
168
+ ## License
169
+
170
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'attr_memoized/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'attr_memoized'
8
+ spec.version = AttrMemoized::VERSION
9
+ spec.authors = ['Konstantin Gredeskoul']
10
+ spec.email = ['kig@reinvent.one']
11
+
12
+ spec.summary = %q{Memoize attributes in a thread-safe way. This ruby gem adds a `#attr_memoized` class method, that provides a lazy-loading mechanism for initializing "heavy" attributes, but in a thread-safe way. Instances thus created can be shared among threads.}
13
+ spec.description = %q{Memoize attributes in a thread-safe way. This ruby gem adds a `#attr_memoized` class method, that provides a lazy-loading mechanism for initializing "heavy" attributes, but in a thread-safe way. Instances thus created can be shared among threads.}
14
+ spec.homepage = 'https://github.com/kigster/attr_memoized'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = 'exe'
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ['lib']
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1'
25
+ spec.add_development_dependency 'rake', '~> 12'
26
+ spec.add_development_dependency 'rspec', '~> 3'
27
+ spec.add_development_dependency 'rspec-its'
28
+ spec.add_development_dependency 'simplecov'
29
+ spec.add_development_dependency 'irbtools'
30
+ spec.add_development_dependency 'colored2'
31
+ spec.add_development_dependency 'codeclimate-test-reporter'
32
+ end
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "attr_memoized"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,224 @@
1
+ require 'attr_memoized/version'
2
+ require 'thread'
3
+ # This module, when included, decorates the receiver with several useful
4
+ # methods:
5
+ #
6
+ # * Both class and instance methods #mutex are added, that can be used
7
+ # to guard any shared resources. Each class gets their own class mutex,
8
+ # and each instance gets it's own separate mutex.
9
+ #
10
+ # * new class method #attr_memoized is added, the syntax is as follows:
11
+ #
12
+ # attr_memoized :attribute_name, ..., -> { block returning a value }
13
+ #
14
+ # * the block in the definition above is called via #instance_exec on the
15
+ # object (instance of a class) and therefore has access to all private
16
+ # methods. If the value is a symbol, it is expected to be a method.
17
+ #
18
+ # * multiple attribute names are allowed in the #attr_memoized, but they
19
+ # will all be assigned the result of the block (last argument) when called.
20
+ # Therefore, typically you would use #attr_memoized with one attribute at
21
+ # a time, unless you want to have several version of the same variable:
22
+ #
23
+ # Example
24
+ # =======
25
+ #
26
+ # class RandomNumbers
27
+ # include AttrMemoized
28
+ #
29
+ # attr_memoized :random, -> { rand(10) }, writer: false
30
+ # attr_memoized :seed, -> { Time.now.to_i % 57 }
31
+ #
32
+ # attr_memoized :number1, :number2, -> { self.class.incr! && rand(10) }
33
+ #
34
+ # @calls = 0
35
+ # class << self
36
+ # attr_reader :calls
37
+ # def incr!; @calls = 0 unless defined(@calls); @calls += 1 ; end
38
+ # end
39
+ # end
40
+ #
41
+ # @rn = RandomNumbers.new
42
+ #
43
+ # # first call executes the block, and caches it
44
+ # @rn.number1 # => 3
45
+ # # and it's saved now, and the block is no longer called
46
+ # @rn.number1 # => 3
47
+ # @rn.number2 # => 9
48
+ # @rn.number2 # => 9
49
+ # # only 2 times did we ever call incr! method
50
+ # @rn.class.calls # => 2
51
+ #
52
+ # # Now #seed is also lazy-loaded, and also cached
53
+ # @rn.seed # => 34
54
+ # # And, we can change it thread safely!
55
+ # @rn.seed = 64; @rn.seed # => 64
56
+ #
57
+ # # Not so with :random, which was defined without the writer:
58
+ # @rn.random # => 8
59
+ # @rn.random = 34
60
+ # # => NoMethodError: undefined method `random=' for #<RandomNumbers:0x007ffb28105178>
61
+ #
62
+ #
63
+ module AttrMemoized
64
+
65
+ # We are being a bit paranoid here, so yes we are creating
66
+ # a central lock used to initialize other class-specific mutexes.
67
+ # This should only be a problem if you are constantly defining new
68
+ # classes that include +AttrMemoized++
69
+ LOCK = Mutex.new.freeze unless defined?(LOCK)
70
+ #
71
+ # The types of initializers we support.
72
+ SUPPORTED_INIT_TYPES = [Proc, Method, Symbol]
73
+
74
+ class << self
75
+ # that's obviously a joke. The name, I mean. Duh.
76
+ attr_accessor :gil
77
+
78
+
79
+ def included(base)
80
+ base.class_eval do
81
+ AttrMemoized::LOCK.synchronize do
82
+ @mutex ||= Mutex.new
83
+ end
84
+
85
+ class << self
86
+ attr_reader :mutex
87
+ #
88
+ # A class method which, for each attribute in the list,
89
+ # creates a thread-safe memoized reader and writer (unless writer: false)
90
+ #
91
+ # Memoized reader accepts <tt>reload: true</tt> as an optional argument,
92
+ # which, if provided, forces reinitialization of the variable.
93
+ #
94
+ # Example:
95
+ # ==========
96
+ #
97
+ # class LazyConnection
98
+ # include AttrMemoized
99
+ # attr_memoized :database_connection, -> { ActiveRecord::Base.connection }
100
+ #
101
+ # end
102
+ #
103
+ # LazyConnection.new.database_connection
104
+ # #=> <ActiveRecord::Connection::PgSQL::Driver:0xff23234f....>
105
+ #
106
+ def attr_memoized(*attributes, **opts)
107
+ attributes = Array[*attributes]
108
+ block_or_proc = attributes.pop if SUPPORTED_INIT_TYPES.include?(attributes.last.class)
109
+ attributes.each do |attribute|
110
+ define_attribute_writer(attribute) unless opts && opts.has_key?(:writer) && opts[:writer].eql?(false)
111
+ define_attribute_reader(attribute, block_or_proc)
112
+ end
113
+ end
114
+
115
+ def define_attribute_reader(attribute, block_or_proc)
116
+ at_attribute = at(attribute)
117
+ define_method(attribute) do |**opts|
118
+ read_memoize(attribute, at_attribute, block_or_proc, **opts)
119
+ end
120
+ end
121
+
122
+ def define_attribute_writer(attribute)
123
+ at_attribute = at(attribute)
124
+ define_method("#{attribute}=".to_sym) do |value|
125
+ block = -> { self.instance_variable_set(at_attribute, value) }
126
+ already_locked? ?
127
+ block.call :
128
+ with_thread_local_lock { mutex.synchronize(&block) }
129
+ value
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ # Convert an attribute name into an @variable syntax
136
+ def at(attr)
137
+ attr = attr.to_sym unless attr.is_a?(Symbol)
138
+ @attr_cache ||= {}
139
+ @attr_cache[attr] || (@attr_cache[attr] = "@#{attr}".to_sym)
140
+ end
141
+ end
142
+
143
+ # instance method: uses the class mutex to create an instance
144
+ # mutex, and then uses the instance mutex to wrap instance's
145
+ # state
146
+ def mutex
147
+ return @mutex if @mutex
148
+ self.class.mutex.synchronize {
149
+ @mutex ||= Mutex.new
150
+ }
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ # This public method is offered in place of a local +#mutex+'s
157
+ # #synchronize method to guard state changes to the object using
158
+ # object's mutex and a thread-local flag. The flag prevents
159
+ # duplicate #synchronize within the same thread on the same +mutex+.
160
+ def with_lock(&block)
161
+ already_locked? ?
162
+ block[] :
163
+ with_thread_local_lock { mutex.synchronize(&block) }
164
+ end
165
+
166
+ private
167
+
168
+ # This private method is executed in order to initialize a memoized
169
+ # attribute.
170
+ #
171
+ # @param [Symbol] attribute - name of the attribute
172
+ # @param [Symbol] at_attribute - symbol representing attribute instance variable
173
+ # @param [Proc, Method, Symbol] block_or_proc - what to call to get the uncached value
174
+ # @param [Hash] opts - additional options
175
+ # @option opts [Boolean] :reload - forces re-initialization of the memoized attribute
176
+ def read_memoize(attribute, at_attribute, block_or_proc, **opts)
177
+ var = self.instance_variable_get(at_attribute)
178
+ return var if var && !reload?(opts)
179
+ with_lock { assign_value(attribute, at_attribute, block_or_proc, **opts) }
180
+ self.instance_variable_get(at_attribute)
181
+ end
182
+
183
+ # This private method resolves the initializer argument and returns it's result.
184
+ def assign_value(attribute, at_attribute, block_or_proc, **opts)
185
+ # reload the value of +var+ because we are now inside a synchronize block
186
+ var = self.instance_variable_get(at_attribute)
187
+ return var if (var && !reload?(opts))
188
+
189
+ # now call whatever `was defined on +attr_memoized+ to get the actual value
190
+ case block_or_proc
191
+ when Symbol
192
+ send(block_or_proc)
193
+ when Method
194
+ block_or_proc.call
195
+ when Proc
196
+ instance_exec(&block_or_proc)
197
+ else
198
+ raise ArgumentError, "expected one of #{AttrMemoized::SUPPORTED_INIT_TYPES.map(&:to_s).join(', ')} for attribute #{attribute}, got #{block_or_proc.class}"
199
+ end.tap do |result|
200
+ self.instance_variable_set(at_attribute, result)
201
+ end
202
+ end
203
+
204
+ # Returns +true+ if +opts+ contains reload: +true+
205
+ def reload?(opts)
206
+ (opts && opts.has_key?(:reload)) ? opts[:reload] : nil
207
+ end
208
+
209
+ # just a key into Thread.local
210
+ def object_locked_key
211
+ @key ||= "this.#{object_id}".to_sym
212
+ end
213
+
214
+ def already_locked?
215
+ Thread.current[object_locked_key]
216
+ end
217
+
218
+ def with_thread_local_lock
219
+ raise ArgumentError, 'Already locked!' if already_locked?
220
+ Thread.current[object_locked_key] = true
221
+ yield if block_given?
222
+ Thread.current[object_locked_key] = nil
223
+ end
224
+ end