ttl_memoizeable 0.2.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: e905652905199d704403098c3e9ac0ff4c6e405cd1a888a949134a434a030856
4
+ data.tar.gz: b21c2af2bddddbcb19e83887c3d0f20e482d80e53835b92ea406d3e0f5ae54e6
5
+ SHA512:
6
+ metadata.gz: 37c2e2d28b4c4b3f5ffaa8143228db8f5063353dbcdb65c0265ef856bc6b473661069b10e183965db23dd8e1cc92dca978b8517e1f99bdb5f34c4809d228fcc2
7
+ data.tar.gz: c73e41dee8ac3a01a8ede3c8783dc7424739fa489abfa4a661c9a72b126b70cb92e52f93fee7de773525aeede26460cd287198e22ace5b269b148ed77744873e
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/testdouble/standard
3
+ ruby_version: 2.6
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ ## [Unreleased]
2
+ ## [0.2.0] - 2023-11-27
3
+ - Publish to rubygems
4
+
5
+ ## [0.1.0] - 2023-11-26
6
+
7
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in ttl_memoizeable.gemspec
6
+ gemspec
7
+
8
+ gem "rake"
9
+ gem "rspec"
10
+ gem "standard"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Huntress Labs
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.
data/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # TTLMemoizeable
2
+
3
+ Cross-thread memoization with eventual consistency.
4
+
5
+ ## Okay... what?
6
+
7
+ Memoization is popular pattern to reduce expensive computation; you don't need a library for this, despite some [existing to provide better developer ergonomics](https://github.com/matthewrudy/memoist). What is hard, however, is supporting higher-level memoization which can be leveraged across threads and periodically reloads/refreshes. This library is, conceptually, a mix of memoization and in-memory caching with time-to-live expiration/refresh which is thread-safe. It works best for computations or data fetching which:
8
+
9
+ 1. Can be eventually correct; where inconsistent data across processes is acceptable.
10
+ - Given two or more processes, one may have "stale" data while the other may have "less-stale" data. There are no cross-process data consistency guarantees.
11
+ 2. Happens in any given thread in a given process.
12
+ - Since this library memoizes data in-memory it may result in poorly allocated memory consumption if only the occasional thread needs the data.
13
+
14
+ This library is a sharp knife with a specific use-case. Do not use it without fully understanding the implications of its application.
15
+
16
+ ## Impetus
17
+
18
+ Extracted from the scaling pains we experienced over at [Huntress Labs](https://www.huntress.com) (the scale of many billions of ruby background jobs per month, millions of HTTP requests per minute), this pattern has allowed us to reduce the execution time of hot code paths where every computation, database query, and HTTP request matters. We discovered these code paths were accessing infrequently changing data sets in each execution and began investigating ways to reduce the overhead of their access. Since it's inception, this library has been used widely across our code bases.
19
+
20
+ ## Benchmark
21
+
22
+ ```ruby
23
+ require "benchmark"
24
+ require "ttl_memoizeable"
25
+
26
+ class ApplicationConfig
27
+ class << self
28
+ def config_without_ttl_memoization
29
+ # JSON.parse($redis.get("some_big_json_string")) => 0.05ms of execution time
30
+ sleep 0.05
31
+ end
32
+
33
+ def config_with_ttl_memoization
34
+ # JSON.parse($redis.get("some_big_json_string")) => 0.05ms of execution time
35
+ sleep 0.05
36
+ end
37
+
38
+ extend TTLMemoizeable
39
+ ttl_memoized_method :config_with_ttl_memoization, ttl: 1000
40
+ end
41
+ end
42
+
43
+ iterations_per_thread = 1000
44
+ thread_count = 4
45
+
46
+ Benchmark.bm do |x|
47
+ x.report("baseline:") do
48
+ thread_count.times.collect do
49
+ Thread.new do
50
+ iterations_per_thread.times do
51
+ ApplicationConfig.config_without_ttl_memoization
52
+ end
53
+ end
54
+ end.each(&:join)
55
+ end
56
+
57
+ x.report("ttl_memoized:") do
58
+ thread_count.times.collect do
59
+ Thread.new do
60
+ iterations_per_thread.times do
61
+ ApplicationConfig.config_with_ttl_memoization
62
+ end
63
+ end
64
+ end.each(&:join)
65
+ end
66
+ end
67
+ ```
68
+
69
+ ```
70
+ user system total real
71
+ baseline: 0.112220 0.101602 0.213822 ( 52.803622)
72
+ ttl_memoized: 0.008847 0.000755 0.009602 ( 0.221783)
73
+ ```
74
+
75
+ ## Usage
76
+
77
+ 1. Define your method as you normally would. Test it. Benchmark it to know that it is "expensive"
78
+ 2. Extend the methods defined in this file by calling `extend TTLMemoizeable` in your class (if not already extended)
79
+ 3. Call `ttl_memoized_method :your_method_name, ttl: 5.minutes` where `:your_method_name` is the method you just defined, and the `ttl` is the duration (in time or accessor counts) of acceptable data inconsistency
80
+ 4. 🎉
81
+
82
+ ### TTL Types:
83
+ Two methods of TTL expiration are available
84
+ 1. Time Duration (i.e `5.minutes`). This will ensure the process will cache your method
85
+ for that given amount of time. This option is likely best when you can quantify the
86
+ acceptable threshold for stale data. Every time the memoized method is called, the date
87
+ the current memoized value was fetched + your ttl value will be compared to the current time.
88
+
89
+ 2. Accessor count (i.e. 10_000). This will ensure the process will cache your method
90
+ for that number of attempts to access the data. This option is likely best when you
91
+ want to TTL to expire based of volume. Every time the memoized method is called, the counter
92
+ will decrement by 1.
93
+
94
+
95
+ ### Dont's
96
+
97
+ 1. Use this library on methods that have logic involving state
98
+ 2. Use this library on methods that accept parameters, as that introduces state; see above
99
+
100
+
101
+ Using this library is most effective on class methods.
102
+
103
+ ```ruby
104
+ require "ttl_memoizeable"
105
+
106
+ class ApplicationConfig
107
+ class << self
108
+ extend TTLMemoizeable
109
+
110
+ def config
111
+ JSON.parse($redis.get("some_big_json_string"))
112
+ end
113
+
114
+ ttl_memoized_method :config, ttl: 1.minute # Redis/JSON.parse will only be hit once per minute from this process
115
+ end
116
+ end
117
+
118
+ ApplicationConfig.config # => {...} Redis/JSON.parse will be called
119
+ ApplicationConfig.config # => {...} Redis/JSON.parse will NOT be called
120
+ #... at least 1 minute later ...
121
+ ApplicationConfig.config # => {...} Redis/JSON.parse will be called
122
+ ```
123
+
124
+
125
+ It will work on instance methods as well, however, this is less useful as it does not share state across threads without the use of a global
126
+ ```ruby
127
+ require "ttl_memoizeable"
128
+
129
+ class ApplicationConfig
130
+ extend TTLMemoizeable
131
+
132
+ def config
133
+ JSON.parse($redis.get("some_big_json_string"))
134
+ end
135
+
136
+ ttl_memoized_method :config, ttl: 1.minute
137
+ end
138
+
139
+ ApplicationConfig.new.config # => {...} Redis/JSON.parse will be called
140
+ ApplicationConfig.new.config # => {...} Redis/JSON.parse will be called
141
+
142
+ application_config = ApplicationConfig.new
143
+ application_config.config # => {...} Redis/JSON.parse will be called
144
+ application_config.config # => {...} Redis/JSON.parse will NOT be called
145
+ #... at least 1 minute later ...
146
+ application_config.config # => {...} Redis/JSON.parse will be called
147
+ ```
148
+
149
+ ## Testing a TTLMemoized Method
150
+
151
+ You likely don't want to test the implementation of this library, but the logic of your memoized method. In that case you probably want "fresh" data on every invocation of the method. There are two approaches, depending on your preference of flavor.
152
+
153
+ 1. Use the reset method provided for you. It follows the pattern of `reset_memoized_value_for_#{method_name}`. Note that this will only reset the value for the current thread, and shouldn't be used to try and create consistent data state across processes.
154
+ ```ruby
155
+ def test_config
156
+ ApplicationConfig.reset_memoized_value_for_config # or in a setup method or before block if available
157
+
158
+ assert_equal {...}, ApplicationConfig.config
159
+ end
160
+ ```
161
+
162
+ 2. Conditionally TTL memoize the method based on test environment or some other condition.
163
+ ```ruby
164
+ def config
165
+ JSON.parse($redis.get("some_big_json_string"))
166
+ end
167
+
168
+ ttl_memoized_method :config, ttl: 1.minute unless test_env?
169
+ ```
170
+
171
+ ## Development
172
+
173
+ 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.
174
+
175
+ 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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
176
+
177
+ ## Contributing
178
+
179
+ Bug reports and pull requests are welcome on GitHub at https://github.com/huntresslabs/ttl_memoizeable.
180
+
181
+ ### Publish a new version
182
+
183
+ `bundle exec bump ${major / minor / patch / pre} --tag --edit-changelog`
184
+ `git push`
185
+ `git push --tags`
186
+ `gem build`
187
+ `gem push`
188
+
189
+ ## License
190
+
191
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTLMemoizeable
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/integer/time"
5
+
6
+ require_relative "ttl_memoizeable/version"
7
+
8
+ module TTLMemoizeable
9
+ TTLMemoizationError = Class.new(StandardError)
10
+
11
+ def ttl_memoized_method(method_name, ttl: 1000)
12
+ raise TTLMemoizationError, "Method not defined: #{method_name}" unless method_defined?(method_name) || private_method_defined?(method_name)
13
+
14
+ ivar_name = method_name.to_s.gsub(/\??/, "") # remove trailing question marks
15
+ time_based_ttl = ttl.is_a?(ActiveSupport::Duration)
16
+ expired_ttl = time_based_ttl ? 1.year.ago : 1
17
+
18
+ ttl_variable_name = "@_ttl_for_#{ivar_name}".to_sym
19
+ mutex_variable_name = "@_mutex_for_#{ivar_name}".to_sym
20
+ value_variable_name = "@_value_for_#{ivar_name}".to_sym
21
+
22
+ reset_memoized_value_method_name = "reset_memoized_value_for_#{method_name}".to_sym
23
+ setup_memoization_method_name = "_setup_memoization_for_#{method_name}".to_sym
24
+ decrement_ttl_method_name = "_decrement_ttl_for_#{method_name}".to_sym
25
+ ttl_exceeded_method_name = "_ttl_exceeded_for_#{method_name}".to_sym
26
+ extend_ttl_method_name = "_extend_ttl_for_#{method_name}".to_sym
27
+
28
+ [
29
+ reset_memoized_value_method_name, setup_memoization_method_name,
30
+ decrement_ttl_method_name, ttl_exceeded_method_name, extend_ttl_method_name
31
+ ].each do |potential_method_name|
32
+ raise TTLMemoizationError, "Method name conflict: #{potential_method_name}" if method_defined?(potential_method_name)
33
+ end
34
+
35
+ memoized_module = Module.new do
36
+ define_method reset_memoized_value_method_name do
37
+ send setup_memoization_method_name if instance_variable_get(mutex_variable_name).nil?
38
+
39
+ instance_variable_get(mutex_variable_name).synchronize do
40
+ instance_variable_set(ttl_variable_name, expired_ttl)
41
+ end
42
+
43
+ nil
44
+ end
45
+
46
+ define_method setup_memoization_method_name do
47
+ instance_variable_set(ttl_variable_name, expired_ttl) unless instance_variable_defined?(ttl_variable_name)
48
+ instance_variable_set(mutex_variable_name, Mutex.new) unless instance_variable_defined?(mutex_variable_name)
49
+ instance_variable_set(value_variable_name, nil) unless instance_variable_defined?(value_variable_name)
50
+ end
51
+
52
+ define_method decrement_ttl_method_name do
53
+ return if time_based_ttl
54
+
55
+ instance_variable_set(ttl_variable_name, instance_variable_get(ttl_variable_name) - 1)
56
+ end
57
+
58
+ define_method ttl_exceeded_method_name do
59
+ instance_variable_get(ttl_variable_name) <= if time_based_ttl
60
+ ttl.ago
61
+ else
62
+ 0
63
+ end
64
+ end
65
+
66
+ define_method extend_ttl_method_name do
67
+ if time_based_ttl
68
+ instance_variable_set(ttl_variable_name, Time.current)
69
+ else
70
+ instance_variable_set(ttl_variable_name, ttl)
71
+ end
72
+ end
73
+
74
+ define_method method_name do |*args|
75
+ raise ArgumentError, "Cannot cache method which requires arguments" if args.size.positive?
76
+
77
+ send setup_memoization_method_name
78
+
79
+ instance_variable_get(mutex_variable_name).synchronize do
80
+ send(decrement_ttl_method_name)
81
+
82
+ if send(ttl_exceeded_method_name)
83
+ send(extend_ttl_method_name)
84
+
85
+ # Refresh value from the original method
86
+ instance_variable_set(value_variable_name, super())
87
+ end
88
+ end
89
+
90
+ instance_variable_get(value_variable_name)
91
+ end
92
+ end
93
+
94
+ prepend memoized_module
95
+ end
96
+ end
@@ -0,0 +1,4 @@
1
+ module TTLMemoizeable
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ttl_memoizeable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel Westendorf
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-11-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
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: bump
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: A sharp knife for cross-thread memoization providing eventual consistency.
42
+ email:
43
+ - daniel@prowestech.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rspec"
49
+ - ".standard.yml"
50
+ - CHANGELOG.md
51
+ - Gemfile
52
+ - LICENSE.txt
53
+ - README.md
54
+ - Rakefile
55
+ - lib/ttl_memoizeable.rb
56
+ - lib/ttl_memoizeable/version.rb
57
+ - sig/ttl_memoizeable.rbs
58
+ homepage: https://github.com/huntresslabs/ttl_memoizeable
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ homepage_uri: https://github.com/huntresslabs/ttl_memoizeable
63
+ source_code_uri: https://github.com/huntresslabs/ttl_memoizeable
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 2.7.0
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.4.1
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Cross-thread memoization in ruby with eventual consistency.
83
+ test_files: []