ttl_memoizeable 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +7 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +191 -0
- data/Rakefile +10 -0
- data/lib/ttl_memoizeable/version.rb +5 -0
- data/lib/ttl_memoizeable.rb +96 -0
- data/sig/ttl_memoizeable.rbs +4 -0
- metadata +83 -0
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
data/.standard.yml
ADDED
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
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,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
|
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: []
|