lru_redux_store 0.1.0.alpha
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 +7 -0
- data/.gitignore +9 -0
- data/.rspec +1 -0
- data/.rubocop.yml +35 -0
- data/.rubocop_todo.yml +0 -0
- data/.ruby-version +1 -0
- data/Appraisals +17 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +72 -0
- data/Rakefile +6 -0
- data/bench/README.md +79 -0
- data/bench/bench.rb +44 -0
- data/bench/bench_prune.rb +119 -0
- data/bench/bench_prune_ttl.rb +121 -0
- data/bench/bench_ttl.rb +40 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/active_support/cache/lru_redux_store.rb +12 -0
- data/lib/lru_redux_store/dup_coder.rb +58 -0
- data/lib/lru_redux_store/store.rb +186 -0
- data/lib/lru_redux_store/version.rb +6 -0
- data/lib/lru_redux_store.rb +15 -0
- data/lru_redux_store.gemspec +53 -0
- data/sig/lru_redux_store.rbs +4 -0
- metadata +266 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6bcc0e3bda88de5344be1eed33c06f985ad9ec76e252cfd4732830c24afe5760
|
|
4
|
+
data.tar.gz: 53478ad2d249f38690225ca43606f5e290cf6ab16de659f3d067459cf08ae9b8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c148200eb44a574e137cf57b4c9340055ed261db1c68cd54e0c8d02dbcfac1140893ec58576675cb2adbafdce584db4f6d14e56e55936a99380daea0e445ea3a
|
|
7
|
+
data.tar.gz: 1104df0afb5b9112424b5c0366cd98d2e38d59fe10df7420d68ef9649656b6ae8401986169662091993244399a6c1bc090574a918dc87b2d14480e3ba001cd99
|
data/.gitignore
ADDED
data/.rspec
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
--require spec_helper
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
inherit_from:
|
|
2
|
+
- .rubocop_todo.yml
|
|
3
|
+
|
|
4
|
+
plugins:
|
|
5
|
+
- rubocop-rails
|
|
6
|
+
- rubocop-performance
|
|
7
|
+
- rubocop-rspec
|
|
8
|
+
|
|
9
|
+
AllCops:
|
|
10
|
+
NewCops: enable
|
|
11
|
+
TargetRubyVersion: 3.3
|
|
12
|
+
SuggestExtensions: false
|
|
13
|
+
Exclude:
|
|
14
|
+
- bench/**/*
|
|
15
|
+
|
|
16
|
+
Style/Documentation:
|
|
17
|
+
Enabled: false
|
|
18
|
+
|
|
19
|
+
Gemspec/DevelopmentDependencies:
|
|
20
|
+
Enabled: false
|
|
21
|
+
|
|
22
|
+
# One class implements the entire cache store API surface.
|
|
23
|
+
Metrics/ClassLength:
|
|
24
|
+
Max: 110
|
|
25
|
+
|
|
26
|
+
# This gem runs outside Rails: no Time.zone is configured, and wall-clock
|
|
27
|
+
# Time.now is what the store and the backing sin_lru_redux cache use.
|
|
28
|
+
Rails/TimeZone:
|
|
29
|
+
Enabled: false
|
|
30
|
+
|
|
31
|
+
# ActiveSupport::Cache::Store#fetch takes options as its second positional
|
|
32
|
+
# argument, not a default value, so the Hash#fetch rewrite does not apply.
|
|
33
|
+
Style/RedundantFetchBlock:
|
|
34
|
+
Exclude:
|
|
35
|
+
- spec/**/*_spec.rb
|
data/.rubocop_todo.yml
ADDED
|
File without changes
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.5
|
data/Appraisals
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
appraise 'rails-7.2' do
|
|
4
|
+
gem 'rails', '~> 7.2.0'
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
appraise 'rails-8.0' do
|
|
8
|
+
gem 'rails', '~> 8.0.2'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
appraise 'rails-8.1' do
|
|
12
|
+
gem 'rails', '~> 8.1.0'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
appraise 'rails-edge' do
|
|
16
|
+
gem 'rails', github: 'rails/rails'
|
|
17
|
+
end
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jonathan schatz
|
|
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,72 @@
|
|
|
1
|
+
# LruReduxStore
|
|
2
|
+
|
|
3
|
+
This gem provides an [`ActiveSupport::Cache::Store`](https://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html) implementation backed by [`sin_lru_redux`](https://github.com/cadenza-tech/sin_lru_redux), an "efficient and thread-safe LRU cache". It's a drop-in alternative to `ActiveSupport::Cache::MemoryStore` that's bounded by entry count instead of estimated byte size.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
This gem requires `activesupport` >= `7.2` and `ruby` >= `3.3`.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle add lru_redux_store
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install lru_redux_store
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Problem
|
|
24
|
+
|
|
25
|
+
`ActiveSupport::Cache::MemoryStore` does its housekeeping in batches. Expired entries pile up until a write pushes the cache over its size cap, and that write then sweeps the whole cache: it scans every entry for expired ones and deletes least recently used entries until enough space is free. The sweep runs while holding the lock every other cache operation needs, so one unlucky request stalls for the whole thing and every concurrent request queues behind it.
|
|
26
|
+
|
|
27
|
+
This gem does the same housekeeping incrementally. Each access evicts whatever has already expired, and a write that overflows `max_size` evicts a single entry, so the work is spread across every operation instead of concentrated in occasional pauses.
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
The store keeps its data in a `LruRedux::TTL::ThreadSafeCache`. `max_size` caps the number of entries. When a write would exceed it, the least recently used entry is evicted.
|
|
32
|
+
|
|
33
|
+
If you pass `expires_in` the backing cache enforces it as a Time To Live eviction strategy. TTL eviction occurs on every access and takes precedence over LRU eviction, meaning a 'live' value will never be evicted over an expired one. `cleanup` triggers a TTL eviction manually. Without `expires_in` the behavior is identical to a plain LRU cache: nothing ever expires and entries only leave through LRU eviction, `delete`, `delete_matched`, or `clear`.
|
|
34
|
+
|
|
35
|
+
Values are copied on the way in and out, matching `MemoryStore`. The coder is a vendored copy of `MemoryStore`'s `DupCoder`: strings are duplicated and everything else takes a `Marshal` round-trip, so a write stores a private copy and every read returns a fresh one. Mutating a value you got from `fetch` never affects other readers.
|
|
36
|
+
|
|
37
|
+
Like `MemoryStore` this cache is per-process. Each process gets its own copy and they don't talk to each other, so don't use it for anything that needs to be consistent across processes.
|
|
38
|
+
|
|
39
|
+
`increment`, `decrement`, `delete_matched`, and `cleanup` are all supported and emit the same `cache_*` instrumentation events as `MemoryStore`, so `ActiveSupport::Notifications` subscribers and log subscribers work unchanged.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
config.cache_store = :lru_redux_store, { max_size: 10_000, expires_in: 1.hour }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Both options are optional. `max_size` defaults to `1000`. Leave `expires_in` off and entries never expire, they just age out of the LRU.
|
|
48
|
+
|
|
49
|
+
You can also use the store directly:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
cache = LruReduxStore::Store.new(max_size: 10_000)
|
|
53
|
+
cache.fetch('some-key') { expensive_computation }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`expires_in` passed to an individual `write` or `fetch` call is honored the same way it is with any other cache store.
|
|
57
|
+
|
|
58
|
+
## Benchmarks
|
|
59
|
+
|
|
60
|
+
tl;dr - this gem trades a little per-operation speed for the absence of large pauses
|
|
61
|
+
|
|
62
|
+
See [bench/README.md](bench/README.md) for the setup and full numbers. Reads and writes are slightly slower than `MemoryStore` because eviction and expiry work happens on every operation, but there's never a full-cache sweep, so worst-case latency stays flat where `MemoryStore` stalls for several milliseconds at a time (in the TTL benchmark those sweeps eat over 20% of total write time).
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
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).
|
|
69
|
+
|
|
70
|
+
## Contributing
|
|
71
|
+
|
|
72
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/modosc/lru_redux_store.
|
data/Rakefile
ADDED
data/bench/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Benchmarks
|
|
2
|
+
|
|
3
|
+
Run these with `bundle exec`:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bundle exec ruby bench/bench.rb
|
|
7
|
+
bundle exec ruby bench/bench_ttl.rb
|
|
8
|
+
bundle exec ruby bench/bench_prune.rb
|
|
9
|
+
bundle exec ruby bench/bench_prune_ttl.rb
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`bench.rb` and `bench_ttl.rb` measure throughput, with and without a TTL. `bench_prune.rb` and `bench_prune_ttl.rb` measure per-write latency under sustained eviction pressure. MemoryStore is bounded by bytes and LruReduxStore by entries, so the latency benchmarks calibrate themselves first: they run the same write stream against a throwaway MemoryStore, record how many entries it holds when its first prune fires, and use that count as LruReduxStore's `max_size`. Both stores reach capacity and start evicting at the same write.
|
|
13
|
+
|
|
14
|
+
## Results
|
|
15
|
+
|
|
16
|
+
Apple silicon laptop, ruby 4.0.2, activesupport 8.1.3. 1m operations for the throughput runs, 200k unique-key writes for the latency runs.
|
|
17
|
+
|
|
18
|
+
### Throughput
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
** LRU Benchmarks (no TTL) **
|
|
22
|
+
user system total real
|
|
23
|
+
LruRedux::ThreadSafeCache 0.231287 0.000707 0.231994 ( 0.232720)
|
|
24
|
+
LruRedux::TTL::ThreadSafeCache (TTL disabled) 0.339670 0.000962 0.340632 ( 0.340927)
|
|
25
|
+
ActiveSupport::Cache::MemoryStore 1.897356 0.006356 1.903712 ( 1.905111)
|
|
26
|
+
LruReduxStore::Store 2.148409 0.007220 2.155629 ( 2.158542)
|
|
27
|
+
LruReduxStore::Store (1k entries, ~50% miss) 3.451640 0.013249 3.464889 ( 3.468145)
|
|
28
|
+
|
|
29
|
+
** TTL Benchmarks **
|
|
30
|
+
user system total real
|
|
31
|
+
LruRedux::TTL::ThreadSafeCache 0.619437 0.002868 0.622305 ( 0.622808)
|
|
32
|
+
ActiveSupport::Cache::MemoryStore (TTL enabled) 2.095064 0.011191 2.106255 ( 2.110231)
|
|
33
|
+
LruReduxStore::Store (TTL enabled) 2.829217 0.024145 2.853362 ( 2.881794)
|
|
34
|
+
LruReduxStore::Store (TTL, 1k entries, ~50% miss) 4.601842 0.031277 4.633119 ( 4.651033)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Write latency under eviction pressure
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
** Write latency under sustained eviction pressure (200000 unique-key writes, no TTL) **
|
|
41
|
+
|
|
42
|
+
calibration: a 4mb MemoryStore holds 12049 entries of this shape; its first prune fired at write 12050.
|
|
43
|
+
LruReduxStore gets max_size 12049, so its first eviction happens at write 12050.
|
|
44
|
+
|
|
45
|
+
ActiveSupport::Cache::MemoryStore (size: 4mb)
|
|
46
|
+
total 0.58s p50 2.00us p99 3.00us max 2998.00us
|
|
47
|
+
entries at end: 10862, evictions: 189138
|
|
48
|
+
prune events: 63, longest 1.88ms, cumulative 79.40ms (13.7% of total time)
|
|
49
|
+
cleanup sweeps: 63, longest 1.17ms, cumulative 47.10ms (8.2% of total time)
|
|
50
|
+
|
|
51
|
+
LruReduxStore::Store (max_size: 12049)
|
|
52
|
+
total 0.71s p50 3.00us p99 9.00us max 3805.00us
|
|
53
|
+
entries at end: 12049, evictions: 187951
|
|
54
|
+
prune events: none
|
|
55
|
+
cleanup sweeps: none
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
** Write latency under sustained expiry pressure (200000 unique-key writes, 25ms TTL) **
|
|
60
|
+
|
|
61
|
+
calibration: a 4mb MemoryStore holds 12049 entries of this shape; its first prune fired at write 12050.
|
|
62
|
+
LruReduxStore gets max_size 12049 and the same 25ms TTL.
|
|
63
|
+
|
|
64
|
+
ActiveSupport::Cache::MemoryStore (size: 4mb, expires_in: 0.025)
|
|
65
|
+
total 0.64s p50 2.00us p99 3.00us max 6957.00us
|
|
66
|
+
entries at end: 11666, evicted or expired: 188334
|
|
67
|
+
prune events: 40, longest 0.02ms, cumulative 0.29ms (0.0% of total time)
|
|
68
|
+
cleanup sweeps: 40, longest 6.86ms, cumulative 139.37ms (21.6% of total time)
|
|
69
|
+
|
|
70
|
+
LruReduxStore::Store (max_size: 12049, expires_in: 0.025)
|
|
71
|
+
total 0.85s p50 3.00us p99 9.00us max 1724.00us
|
|
72
|
+
entries at end: 6334, evicted or expired: 193666
|
|
73
|
+
prune events: none
|
|
74
|
+
cleanup sweeps: none
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Analysis
|
|
78
|
+
|
|
79
|
+
Median write latency is identical and throughput is close, with LruReduxStore a little slower per operation since it pays for eviction and expiry on every write. The difference is where MemoryStore does that work: synchronized prune and cleanup passes that stall a single write for several milliseconds while every other thread queues behind the lock, and once entries are actually expiring those sweeps eat over 20% of total write time. LruReduxStore has no batch phase at all, so its worst case stays flat.
|
data/bench/bench.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'benchmark'
|
|
5
|
+
require 'lru_redux'
|
|
6
|
+
require 'active_support'
|
|
7
|
+
require 'active_support/cache'
|
|
8
|
+
require 'active_support/cache/memory_store'
|
|
9
|
+
require 'active_support/notifications'
|
|
10
|
+
require 'lru_redux_store'
|
|
11
|
+
|
|
12
|
+
# Raw sin_lru_redux caches, for reference.
|
|
13
|
+
redux_thread_safe = LruRedux::ThreadSafeCache.new(1_000)
|
|
14
|
+
redux_ttl_disabled = LruRedux::TTL::ThreadSafeCache.new(1_000)
|
|
15
|
+
|
|
16
|
+
# MemoryStore's 32mb default cap holds the entire rand(2_000) keyspace (~100%
|
|
17
|
+
# hit rate), so give LruReduxStore::Store enough slots to do the same; the
|
|
18
|
+
# bounded variant shows the cost of a ~50% miss rate.
|
|
19
|
+
memory_store = ActiveSupport::Cache::MemoryStore.new
|
|
20
|
+
lru_redux_store = LruReduxStore::Store.new(max_size: 2_000)
|
|
21
|
+
lru_redux_store_bounded = LruReduxStore::Store.new(max_size: 1_000)
|
|
22
|
+
|
|
23
|
+
puts '** LRU Benchmarks (no TTL) **'
|
|
24
|
+
Benchmark.bmbm do |bm|
|
|
25
|
+
bm.report 'LruRedux::ThreadSafeCache' do
|
|
26
|
+
1_000_000.times { redux_thread_safe.getset(rand(2_000)) { :value } }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
bm.report 'LruRedux::TTL::ThreadSafeCache (TTL disabled)' do
|
|
30
|
+
1_000_000.times { redux_ttl_disabled.getset(rand(2_000)) { :value } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
bm.report 'ActiveSupport::Cache::MemoryStore' do
|
|
34
|
+
1_000_000.times { memory_store.fetch(rand(2_000)) { :value } }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
bm.report 'LruReduxStore::Store' do
|
|
38
|
+
1_000_000.times { lru_redux_store.fetch(rand(2_000)) { :value } }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
bm.report 'LruReduxStore::Store (1k entries, ~50% miss)' do
|
|
42
|
+
1_000_000.times { lru_redux_store_bounded.fetch(rand(2_000)) { :value } }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'active_support'
|
|
5
|
+
require 'active_support/cache'
|
|
6
|
+
require 'active_support/cache/memory_store'
|
|
7
|
+
require 'active_support/notifications'
|
|
8
|
+
require 'lru_redux_store'
|
|
9
|
+
|
|
10
|
+
# MemoryStore prunes when a write pushes its estimated byte size over the cap:
|
|
11
|
+
# that write runs cleanup (a scan of every entry in the cache) and then deletes
|
|
12
|
+
# least recently used entries one at a time until the size is back under 75% of
|
|
13
|
+
# the cap, all while holding the monitor every other cache operation needs.
|
|
14
|
+
#
|
|
15
|
+
# The two stores bound themselves differently (bytes vs entries), so to keep
|
|
16
|
+
# the comparison as close as possible the entry cap is calibrated rather than
|
|
17
|
+
# assumed: a throwaway MemoryStore runs the same write stream and we record how
|
|
18
|
+
# many entries it actually holds when its first prune fires. LruReduxStore gets
|
|
19
|
+
# that number as max_size, so both stores reach capacity and start evicting at
|
|
20
|
+
# the same write index. The remaining difference is the thing being measured:
|
|
21
|
+
# MemoryStore evicts ~25% of the cache in one synchronized burst, LruReduxStore
|
|
22
|
+
# evicts one entry per write.
|
|
23
|
+
|
|
24
|
+
VALUE = 'x' * 100
|
|
25
|
+
WRITES = 200_000
|
|
26
|
+
MEMORY_STORE_BYTES = 4 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
def calibrate_capacity
|
|
29
|
+
probe = ActiveSupport::Cache::MemoryStore.new(size: MEMORY_STORE_BYTES)
|
|
30
|
+
data = probe.instance_variable_get(:@data)
|
|
31
|
+
pruned = false
|
|
32
|
+
subscriber = ActiveSupport::Notifications.subscribe('cache_prune.active_support') { pruned = true }
|
|
33
|
+
peak = 0
|
|
34
|
+
writes = 0
|
|
35
|
+
until pruned
|
|
36
|
+
probe.write("key-#{writes}", VALUE)
|
|
37
|
+
writes += 1
|
|
38
|
+
peak = data.size if data.size > peak
|
|
39
|
+
raise 'MemoryStore never pruned during calibration' if writes >= WRITES
|
|
40
|
+
end
|
|
41
|
+
[peak, writes]
|
|
42
|
+
ensure
|
|
43
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def measure_writes(store)
|
|
47
|
+
latencies = Array.new(WRITES)
|
|
48
|
+
WRITES.times do |i|
|
|
49
|
+
key = "key-#{i}"
|
|
50
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
+
store.write(key, VALUE)
|
|
52
|
+
latencies[i] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
53
|
+
end
|
|
54
|
+
latencies.sort!
|
|
55
|
+
{
|
|
56
|
+
total: latencies.sum,
|
|
57
|
+
p50: latencies[WRITES / 2],
|
|
58
|
+
p99: latencies[(WRITES * 0.99).floor],
|
|
59
|
+
max: latencies.last
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def report_events(name, durations, total)
|
|
64
|
+
if durations.empty?
|
|
65
|
+
puts " #{name}: none"
|
|
66
|
+
else
|
|
67
|
+
puts format(' %s: %d, longest %.2fms, cumulative %.2fms (%.1f%% of total time)',
|
|
68
|
+
name, durations.size, durations.max * 1_000, durations.sum * 1_000, (durations.sum / total) * 100)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# MemoryStore#prune runs its cleanup pass (the expired-entry sweep of the
|
|
73
|
+
# whole cache) before its instrument(:prune) block starts, so prune event
|
|
74
|
+
# durations exclude it. cache_cleanup events are tracked separately or that
|
|
75
|
+
# sweep cost would disappear from the numbers.
|
|
76
|
+
def report(label, stats, entries_at_end, prunes, cleanups)
|
|
77
|
+
puts label
|
|
78
|
+
puts format(' total %6.2fs p50 %8.2fus p99 %8.2fus max %10.2fus',
|
|
79
|
+
stats[:total], stats[:p50] * 1_000_000, stats[:p99] * 1_000_000, stats[:max] * 1_000_000)
|
|
80
|
+
puts format(' entries at end: %d, evictions: %d', entries_at_end, WRITES - entries_at_end)
|
|
81
|
+
report_events('prune events', prunes, stats[:total])
|
|
82
|
+
report_events('cleanup sweeps', cleanups, stats[:total])
|
|
83
|
+
puts
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
entry_capacity, first_prune_at = calibrate_capacity
|
|
87
|
+
|
|
88
|
+
puts "** Write latency under sustained eviction pressure (#{WRITES} unique-key writes, no TTL) **"
|
|
89
|
+
puts
|
|
90
|
+
puts format('calibration: a %dmb MemoryStore holds %d entries of this shape; its first prune fired at write %d.',
|
|
91
|
+
MEMORY_STORE_BYTES / 1024 / 1024, entry_capacity, first_prune_at)
|
|
92
|
+
puts format('LruReduxStore gets max_size %d, so its first eviction happens at write %d.',
|
|
93
|
+
entry_capacity, entry_capacity + 1)
|
|
94
|
+
puts
|
|
95
|
+
|
|
96
|
+
prunes = []
|
|
97
|
+
cleanups = []
|
|
98
|
+
prune_subscriber = ActiveSupport::Notifications.subscribe('cache_prune.active_support') do |_name, started, finished, _id, _payload|
|
|
99
|
+
prunes << (finished - started)
|
|
100
|
+
end
|
|
101
|
+
cleanup_subscriber = ActiveSupport::Notifications.subscribe('cache_cleanup.active_support') do |_name, started, finished, _id, _payload|
|
|
102
|
+
cleanups << (finished - started)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
memory_store = ActiveSupport::Cache::MemoryStore.new(size: MEMORY_STORE_BYTES)
|
|
106
|
+
stats = measure_writes(memory_store)
|
|
107
|
+
entries = memory_store.instance_variable_get(:@data).size
|
|
108
|
+
report "ActiveSupport::Cache::MemoryStore (size: #{MEMORY_STORE_BYTES / 1024 / 1024}mb)",
|
|
109
|
+
stats, entries, prunes, cleanups
|
|
110
|
+
|
|
111
|
+
prunes = []
|
|
112
|
+
cleanups = []
|
|
113
|
+
lru_redux_store = LruReduxStore::Store.new(max_size: entry_capacity)
|
|
114
|
+
stats = measure_writes(lru_redux_store)
|
|
115
|
+
entries = lru_redux_store.instance_variable_get(:@data).count
|
|
116
|
+
report "LruReduxStore::Store (max_size: #{entry_capacity})", stats, entries, prunes, cleanups
|
|
117
|
+
|
|
118
|
+
ActiveSupport::Notifications.unsubscribe(prune_subscriber)
|
|
119
|
+
ActiveSupport::Notifications.unsubscribe(cleanup_subscriber)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'active_support'
|
|
5
|
+
require 'active_support/cache'
|
|
6
|
+
require 'active_support/cache/memory_store'
|
|
7
|
+
require 'active_support/notifications'
|
|
8
|
+
require 'lru_redux_store'
|
|
9
|
+
|
|
10
|
+
# TTL variant of bench_prune.rb. The TTL is short enough that entries expire
|
|
11
|
+
# while the benchmark is still running, which is where the two stores differ
|
|
12
|
+
# most. MemoryStore never removes an entry at expiry: expired entries keep
|
|
13
|
+
# counting toward the byte cap until a read touches them or a prune's cleanup
|
|
14
|
+
# phase sweeps the whole cache, so expiration work arrives in the same
|
|
15
|
+
# synchronized bursts as eviction work. LruReduxStore's backing cache pops
|
|
16
|
+
# expired entries incrementally on every access instead.
|
|
17
|
+
#
|
|
18
|
+
# Capacity is calibrated the same way as bench_prune.rb: a throwaway
|
|
19
|
+
# MemoryStore runs the same write stream and we record how many entries it
|
|
20
|
+
# holds when its first prune fires, then LruReduxStore gets that number as
|
|
21
|
+
# max_size. Both stores also get the same TTL.
|
|
22
|
+
|
|
23
|
+
VALUE = 'x' * 100
|
|
24
|
+
WRITES = 200_000
|
|
25
|
+
MEMORY_STORE_BYTES = 4 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
# ~25ms: shorter than the time it takes either store to fill to capacity, so
|
|
28
|
+
# expiry is the dominant force rather than LRU eviction.
|
|
29
|
+
TTL = 0.025
|
|
30
|
+
|
|
31
|
+
def calibrate_capacity
|
|
32
|
+
probe = ActiveSupport::Cache::MemoryStore.new(size: MEMORY_STORE_BYTES, expires_in: TTL)
|
|
33
|
+
data = probe.instance_variable_get(:@data)
|
|
34
|
+
pruned = false
|
|
35
|
+
subscriber = ActiveSupport::Notifications.subscribe('cache_prune.active_support') { pruned = true }
|
|
36
|
+
peak = 0
|
|
37
|
+
writes = 0
|
|
38
|
+
until pruned
|
|
39
|
+
probe.write("key-#{writes}", VALUE)
|
|
40
|
+
writes += 1
|
|
41
|
+
peak = data.size if data.size > peak
|
|
42
|
+
raise 'MemoryStore never pruned during calibration' if writes >= WRITES
|
|
43
|
+
end
|
|
44
|
+
[peak, writes]
|
|
45
|
+
ensure
|
|
46
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def measure_writes(store)
|
|
50
|
+
latencies = Array.new(WRITES)
|
|
51
|
+
WRITES.times do |i|
|
|
52
|
+
key = "key-#{i}"
|
|
53
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
store.write(key, VALUE)
|
|
55
|
+
latencies[i] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
56
|
+
end
|
|
57
|
+
latencies.sort!
|
|
58
|
+
{
|
|
59
|
+
total: latencies.sum,
|
|
60
|
+
p50: latencies[WRITES / 2],
|
|
61
|
+
p99: latencies[(WRITES * 0.99).floor],
|
|
62
|
+
max: latencies.last
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def report_events(name, durations, total)
|
|
67
|
+
if durations.empty?
|
|
68
|
+
puts " #{name}: none"
|
|
69
|
+
else
|
|
70
|
+
puts format(' %s: %d, longest %.2fms, cumulative %.2fms (%.1f%% of total time)',
|
|
71
|
+
name, durations.size, durations.max * 1_000, durations.sum * 1_000, (durations.sum / total) * 100)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# MemoryStore#prune runs its cleanup pass (the expired-entry sweep of the
|
|
76
|
+
# whole cache) before its instrument(:prune) block starts, so prune event
|
|
77
|
+
# durations exclude it. cache_cleanup events are tracked separately or the
|
|
78
|
+
# expiry sweep cost would disappear from the numbers.
|
|
79
|
+
def report(label, stats, entries_at_end, prunes, cleanups)
|
|
80
|
+
puts label
|
|
81
|
+
puts format(' total %6.2fs p50 %8.2fus p99 %8.2fus max %10.2fus',
|
|
82
|
+
stats[:total], stats[:p50] * 1_000_000, stats[:p99] * 1_000_000, stats[:max] * 1_000_000)
|
|
83
|
+
puts format(' entries at end: %d, evicted or expired: %d', entries_at_end, WRITES - entries_at_end)
|
|
84
|
+
report_events('prune events', prunes, stats[:total])
|
|
85
|
+
report_events('cleanup sweeps', cleanups, stats[:total])
|
|
86
|
+
puts
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
entry_capacity, first_prune_at = calibrate_capacity
|
|
90
|
+
|
|
91
|
+
puts "** Write latency under sustained expiry pressure (#{WRITES} unique-key writes, #{(TTL * 1000).to_i}ms TTL) **"
|
|
92
|
+
puts
|
|
93
|
+
puts format('calibration: a %dmb MemoryStore holds %d entries of this shape; its first prune fired at write %d.',
|
|
94
|
+
MEMORY_STORE_BYTES / 1024 / 1024, entry_capacity, first_prune_at)
|
|
95
|
+
puts format('LruReduxStore gets max_size %d and the same %dms TTL.', entry_capacity, (TTL * 1000).to_i)
|
|
96
|
+
puts
|
|
97
|
+
|
|
98
|
+
prunes = []
|
|
99
|
+
cleanups = []
|
|
100
|
+
prune_subscriber = ActiveSupport::Notifications.subscribe('cache_prune.active_support') do |_name, started, finished, _id, _payload|
|
|
101
|
+
prunes << (finished - started)
|
|
102
|
+
end
|
|
103
|
+
cleanup_subscriber = ActiveSupport::Notifications.subscribe('cache_cleanup.active_support') do |_name, started, finished, _id, _payload|
|
|
104
|
+
cleanups << (finished - started)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
memory_store = ActiveSupport::Cache::MemoryStore.new(size: MEMORY_STORE_BYTES, expires_in: TTL)
|
|
108
|
+
stats = measure_writes(memory_store)
|
|
109
|
+
entries = memory_store.instance_variable_get(:@data).size
|
|
110
|
+
report "ActiveSupport::Cache::MemoryStore (size: #{MEMORY_STORE_BYTES / 1024 / 1024}mb, expires_in: #{TTL})",
|
|
111
|
+
stats, entries, prunes, cleanups
|
|
112
|
+
|
|
113
|
+
prunes = []
|
|
114
|
+
cleanups = []
|
|
115
|
+
lru_redux_store = LruReduxStore::Store.new(max_size: entry_capacity, expires_in: TTL)
|
|
116
|
+
stats = measure_writes(lru_redux_store)
|
|
117
|
+
entries = lru_redux_store.instance_variable_get(:@data).count
|
|
118
|
+
report "LruReduxStore::Store (max_size: #{entry_capacity}, expires_in: #{TTL})", stats, entries, prunes, cleanups
|
|
119
|
+
|
|
120
|
+
ActiveSupport::Notifications.unsubscribe(prune_subscriber)
|
|
121
|
+
ActiveSupport::Notifications.unsubscribe(cleanup_subscriber)
|
data/bench/bench_ttl.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'benchmark'
|
|
5
|
+
require 'lru_redux'
|
|
6
|
+
require 'active_support'
|
|
7
|
+
require 'active_support/cache'
|
|
8
|
+
require 'active_support/cache/memory_store'
|
|
9
|
+
require 'active_support/notifications'
|
|
10
|
+
require 'lru_redux_store'
|
|
11
|
+
|
|
12
|
+
TTL = 5 * 60
|
|
13
|
+
|
|
14
|
+
# Raw sin_lru_redux TTL cache, for reference.
|
|
15
|
+
redux_ttl_thread_safe = LruRedux::TTL::ThreadSafeCache.new(1_000, TTL)
|
|
16
|
+
|
|
17
|
+
# Same keyspace/capacity reasoning as bench.rb: 2k slots for the head-to-head
|
|
18
|
+
# with MemoryStore, 1k for the bounded ~50% miss variant.
|
|
19
|
+
memory_store = ActiveSupport::Cache::MemoryStore.new(expires_in: TTL)
|
|
20
|
+
lru_redux_store = LruReduxStore::Store.new(max_size: 2_000, expires_in: TTL)
|
|
21
|
+
lru_redux_store_bounded = LruReduxStore::Store.new(max_size: 1_000, expires_in: TTL)
|
|
22
|
+
|
|
23
|
+
puts '** TTL Benchmarks **'
|
|
24
|
+
Benchmark.bmbm do |bm|
|
|
25
|
+
bm.report 'LruRedux::TTL::ThreadSafeCache' do
|
|
26
|
+
1_000_000.times { redux_ttl_thread_safe.getset(rand(2_000)) { :value } }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
bm.report 'ActiveSupport::Cache::MemoryStore (TTL enabled)' do
|
|
30
|
+
1_000_000.times { memory_store.fetch(rand(2_000)) { :value } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
bm.report 'LruReduxStore::Store (TTL enabled)' do
|
|
34
|
+
1_000_000.times { lru_redux_store.fetch(rand(2_000)) { :value } }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
bm.report 'LruReduxStore::Store (TTL, 1k entries, ~50% miss)' do
|
|
38
|
+
1_000_000.times { lru_redux_store_bounded.fetch(rand(2_000)) { :value } }
|
|
39
|
+
end
|
|
40
|
+
end
|
data/bin/console
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'bundler/setup'
|
|
5
|
+
require 'lru_redux_store'
|
|
6
|
+
|
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
9
|
+
|
|
10
|
+
require 'irb'
|
|
11
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'lru_redux_store'
|
|
4
|
+
|
|
5
|
+
module ActiveSupport # :nodoc:
|
|
6
|
+
module Cache # :nodoc:
|
|
7
|
+
# Registers the store under the name ActiveSupport::Cache expects, so
|
|
8
|
+
# <tt>config.cache_store = :lru_redux_store</tt> resolves to
|
|
9
|
+
# LruReduxStore::Store.
|
|
10
|
+
LruReduxStore = LruReduxStore::Store
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LruReduxStore
|
|
4
|
+
# Vendored copy of ActiveSupport::Cache::MemoryStore::DupCoder (activesupport
|
|
5
|
+
# 8.1.3, MIT license). DupCoder is :nodoc: internal API, so we carry our own
|
|
6
|
+
# copy rather than referencing it:
|
|
7
|
+
# https://github.com/rails/rails/blob/v8.1.3/activesupport/lib/active_support/cache/memory_store.rb
|
|
8
|
+
#
|
|
9
|
+
# Dups string values and marshals everything else so the cache never shares
|
|
10
|
+
# object references with callers. A write stores a private copy and every
|
|
11
|
+
# read returns a fresh one.
|
|
12
|
+
module DupCoder # :nodoc:
|
|
13
|
+
extend self
|
|
14
|
+
|
|
15
|
+
MARSHAL_SIGNATURE = "\x04\x08".b.freeze
|
|
16
|
+
|
|
17
|
+
def dump(entry)
|
|
18
|
+
if entry.value && entry.value != true && !entry.value.is_a?(Numeric)
|
|
19
|
+
ActiveSupport::Cache::Entry.new(dump_value(entry.value), expires_at: entry.expires_at, version: entry.version)
|
|
20
|
+
else
|
|
21
|
+
entry
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dump_compressed(entry, threshold)
|
|
26
|
+
compressed_entry = entry.compressed(threshold)
|
|
27
|
+
compressed_entry.compressed? ? compressed_entry : dump(entry)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def load(entry)
|
|
31
|
+
if !entry.compressed? && entry.value.is_a?(String)
|
|
32
|
+
ActiveSupport::Cache::Entry.new(load_value(entry.value), expires_at: entry.expires_at, version: entry.version)
|
|
33
|
+
else
|
|
34
|
+
entry
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def dump_value(value)
|
|
41
|
+
if value.is_a?(String) && !value.start_with?(MARSHAL_SIGNATURE)
|
|
42
|
+
value.dup
|
|
43
|
+
else
|
|
44
|
+
Marshal.dump(value)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def load_value(string)
|
|
49
|
+
if string.start_with?(MARSHAL_SIGNATURE)
|
|
50
|
+
# only strings produced by dump_value above carry the marshal
|
|
51
|
+
# signature, so this never loads untrusted external data
|
|
52
|
+
Marshal.load(string) # rubocop:disable Security/MarshalLoad
|
|
53
|
+
else
|
|
54
|
+
string.dup
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/cache'
|
|
5
|
+
require 'active_support/core_ext/integer/time'
|
|
6
|
+
require 'objspace'
|
|
7
|
+
require 'sin_lru_redux'
|
|
8
|
+
|
|
9
|
+
module LruReduxStore
|
|
10
|
+
# A thread-safe cache store implementation which stores everything in memory
|
|
11
|
+
# in the same process, backed by LruRedux::TTL::ThreadSafeCache. Unlike
|
|
12
|
+
# ActiveSupport::Cache::MemoryStore it is bounded by entry count rather than
|
|
13
|
+
# estimated byte size, and eviction and expiry happen incrementally on each
|
|
14
|
+
# access instead of in periodic full-cache pruning passes.
|
|
15
|
+
class Store < ActiveSupport::Cache::Store
|
|
16
|
+
# Creates a new store. Accepts the standard ActiveSupport::Cache::Store
|
|
17
|
+
# options plus:
|
|
18
|
+
#
|
|
19
|
+
# * +max_size+ - the maximum number of entries (default 1000). The least
|
|
20
|
+
# recently used entry is evicted when a write would exceed it.
|
|
21
|
+
# * +expires_in+ - optional TTL enforced by the backing cache. Without it
|
|
22
|
+
# entries never expire and only leave through LRU eviction.
|
|
23
|
+
def initialize(options = nil)
|
|
24
|
+
options ||= {}
|
|
25
|
+
# dup/marshal values so the cache never shares object references with
|
|
26
|
+
# callers, matching MemoryStore. pass coder: nil to store raw values
|
|
27
|
+
options[:coder] = DupCoder unless options.key?(:coder) || options.key?(:serializer)
|
|
28
|
+
# Disable compression by default.
|
|
29
|
+
options[:compress] ||= false
|
|
30
|
+
# store-level expiry is enforced solely by the backing TTL cache; keep
|
|
31
|
+
# expires_in out of @options so the base class never embeds it in entries
|
|
32
|
+
@expires_in = options.delete(:expires_in)
|
|
33
|
+
super
|
|
34
|
+
@max_size = options[:max_size] || 1000
|
|
35
|
+
@data = build_data_cache
|
|
36
|
+
|
|
37
|
+
@cache_size = 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Advertise cache versioning support.
|
|
41
|
+
def self.supports_cache_versioning?
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Delete all data stored in a given cache store.
|
|
46
|
+
def clear(_options = nil)
|
|
47
|
+
@data.clear
|
|
48
|
+
@cache_size = 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Deletes cache entries if the cache key matches a given pattern.
|
|
52
|
+
def delete_matched(matcher, options = nil)
|
|
53
|
+
options = merged_options options
|
|
54
|
+
matcher = key_matcher matcher, options
|
|
55
|
+
|
|
56
|
+
instrument :delete_matched, matcher.inspect do
|
|
57
|
+
keys = @data.to_a.map(&:first)
|
|
58
|
+
keys.each do |key|
|
|
59
|
+
delete_entry(key, **options) if key.match matcher
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Preemptively iterates through all stored keys and removes the ones which have expired.
|
|
65
|
+
def cleanup(_options = nil)
|
|
66
|
+
_instrument(:cleanup, size: @data.count) do
|
|
67
|
+
synchronize { @data.expire }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Increment a cached integer value. Returns the updated value.
|
|
72
|
+
#
|
|
73
|
+
# If the key is unset, it will be set to +amount+:
|
|
74
|
+
#
|
|
75
|
+
# cache.increment("foo") # => 1
|
|
76
|
+
# cache.increment("bar", 100) # => 100
|
|
77
|
+
#
|
|
78
|
+
# To set a specific value, call #write:
|
|
79
|
+
#
|
|
80
|
+
# cache.write("baz", 5)
|
|
81
|
+
# cache.increment("baz") # => 6
|
|
82
|
+
#
|
|
83
|
+
def increment(name, amount = 1, **options)
|
|
84
|
+
instrument(:increment, name, amount: amount) do
|
|
85
|
+
modify_value(name, amount, **options)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Decrement a cached integer value. Returns the updated value.
|
|
90
|
+
#
|
|
91
|
+
# If the key is unset or has expired, it will be set to +-amount+.
|
|
92
|
+
#
|
|
93
|
+
# cache.decrement("foo") # => -1
|
|
94
|
+
#
|
|
95
|
+
# To set a specific value, call #write:
|
|
96
|
+
#
|
|
97
|
+
# cache.write("baz", 5)
|
|
98
|
+
# cache.decrement("baz") # => 4
|
|
99
|
+
#
|
|
100
|
+
def decrement(name, amount = 1, **options)
|
|
101
|
+
instrument(:decrement, name, amount: amount) do
|
|
102
|
+
modify_value(name, -amount, **options)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Synchronize calls to the cache. This should be called wherever the underlying cache implementation
|
|
107
|
+
# is not thread safe.
|
|
108
|
+
def synchronize(&) # :nodoc:
|
|
109
|
+
@data.synchronize(&)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def inspect # :nodoc:
|
|
113
|
+
"#<#{self.class.name} entries=#{@data.count}, size=#{@cache_size}, options=#{@options.inspect}>"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Fixed per-entry bookkeeping cost in bytes, counted toward the reported
|
|
117
|
+
# cache size on top of each key and payload. Matches MemoryStore.
|
|
118
|
+
PER_ENTRY_OVERHEAD = 240
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def build_data_cache
|
|
123
|
+
if @expires_in
|
|
124
|
+
LruRedux::TTL::ThreadSafeCache.new @max_size, @expires_in.to_f
|
|
125
|
+
else
|
|
126
|
+
# the TTL argument defaults to :none, and the behavior of a TTL cache
|
|
127
|
+
# with the TTL set to :none is identical to the LRU cache
|
|
128
|
+
LruRedux::TTL::ThreadSafeCache.new @max_size
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def cached_size(key, payload)
|
|
133
|
+
ObjectSpace.memsize_of(key.to_s) + ObjectSpace.memsize_of(payload) + PER_ENTRY_OVERHEAD
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def read_entry(key, **_options)
|
|
137
|
+
deserialize_entry(@data[key])
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def exists?(key, **_options)
|
|
141
|
+
@data.key? key
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def write_entry(key, entry, **options) # rubocop:disable Naming/PredicateMethod
|
|
145
|
+
return false if options[:unless_exist] && exist?(key, namespace: nil)
|
|
146
|
+
|
|
147
|
+
payload = serialize_entry(entry, **options)
|
|
148
|
+
old_payload = @data[key]
|
|
149
|
+
if old_payload
|
|
150
|
+
@cache_size -= (ObjectSpace.memsize_of(old_payload) - ObjectSpace.memsize_of(payload))
|
|
151
|
+
else
|
|
152
|
+
@cache_size += cached_size(key, payload)
|
|
153
|
+
end
|
|
154
|
+
@data[key] = payload
|
|
155
|
+
true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def delete_entry(key, **_options) # rubocop:disable Naming/PredicateMethod
|
|
159
|
+
payload = @data.delete key
|
|
160
|
+
@cache_size -= cached_size(key, payload) if payload
|
|
161
|
+
!!payload
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Modifies the amount of an integer value that is stored in the cache.
|
|
165
|
+
# If the key is not found it is created and set to +amount+.
|
|
166
|
+
def modify_value(name, amount, **options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
167
|
+
options = merged_options options
|
|
168
|
+
key = normalize_key name, options
|
|
169
|
+
version = normalize_version name, options
|
|
170
|
+
|
|
171
|
+
synchronize do
|
|
172
|
+
entry = read_entry(key, **options)
|
|
173
|
+
|
|
174
|
+
if !entry || entry.expired? || entry.mismatched?(version)
|
|
175
|
+
write(name, Integer(amount), options)
|
|
176
|
+
amount
|
|
177
|
+
else
|
|
178
|
+
num = entry.value.to_i + amount
|
|
179
|
+
entry = ActiveSupport::Cache::Entry.new(num, version: entry.version)
|
|
180
|
+
write_entry(key, entry)
|
|
181
|
+
num
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zeitwerk'
|
|
4
|
+
loader = Zeitwerk::Loader.for_gem
|
|
5
|
+
loader.ignore("#{__dir__}/active_support")
|
|
6
|
+
loader.setup
|
|
7
|
+
|
|
8
|
+
require 'active_support/cache'
|
|
9
|
+
|
|
10
|
+
# An ActiveSupport::Cache::Store implementation backed by sin_lru_redux, an
|
|
11
|
+
# efficient and thread-safe LRU cache. See LruReduxStore::Store.
|
|
12
|
+
module LruReduxStore
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
loader.eager_load
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
+
require 'lru_redux_store/version'
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = 'lru_redux_store'
|
|
9
|
+
spec.version = LruReduxStore::VERSION
|
|
10
|
+
spec.authors = ['jonathan schatz']
|
|
11
|
+
spec.email = ['modosc@users.noreply.github.com']
|
|
12
|
+
|
|
13
|
+
spec.summary = 'An ActiveSupport::Cache::Store backed by sin_lru_redux.'
|
|
14
|
+
spec.description = 'A bounded, thread-safe, in-memory cache store for Rails. ' \
|
|
15
|
+
'Backed by sin_lru_redux for O(1) LRU eviction with optional TTL, ' \
|
|
16
|
+
'it stores raw values with no serialization and emits the same ' \
|
|
17
|
+
'interface as ActiveSupport::Cache::Store.'
|
|
18
|
+
spec.homepage = 'https://github.com/modosc/lru_redux_store'
|
|
19
|
+
spec.required_ruby_version = '>= 3.3.0'
|
|
20
|
+
|
|
21
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
|
22
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
23
|
+
spec.metadata['source_code_uri'] = 'https://github.com/modosc/lru_redux_store'
|
|
24
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
25
|
+
spec.metadata['licenses'] = 'MIT'
|
|
26
|
+
|
|
27
|
+
# Specify which files should be added to the gem when it is released.
|
|
28
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
29
|
+
File.basename(__FILE__)
|
|
30
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
31
|
+
spec.bindir = 'exe'
|
|
32
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
33
|
+
spec.require_paths = ['lib']
|
|
34
|
+
spec.add_development_dependency 'appraisal', '~> 2.5.0'
|
|
35
|
+
spec.add_development_dependency 'benchmark'
|
|
36
|
+
spec.add_development_dependency 'bundler', '>= 2.4.18'
|
|
37
|
+
spec.add_development_dependency 'pry-byebug'
|
|
38
|
+
spec.add_development_dependency 'rake', '~> 13.4.0'
|
|
39
|
+
spec.add_development_dependency 'rspec', '~> 3.13.0'
|
|
40
|
+
spec.add_development_dependency 'rspec-rails', '~> 8.0.2'
|
|
41
|
+
spec.add_development_dependency 'rubocop', '~> 1.88.2'
|
|
42
|
+
spec.add_development_dependency 'rubocop-performance', '~> 1.26.0'
|
|
43
|
+
spec.add_development_dependency 'rubocop-rails', '~> 2.35.5'
|
|
44
|
+
spec.add_development_dependency 'rubocop-rspec', '~> 3.10.2'
|
|
45
|
+
spec.add_dependency 'activesupport', '>= 7.2.0'
|
|
46
|
+
spec.add_dependency 'sin_lru_redux', '>= 2.5.2'
|
|
47
|
+
spec.add_dependency 'zeitwerk', '>= 2.5.0'
|
|
48
|
+
# Uncomment to register a new dependency of your gem
|
|
49
|
+
# spec.add_dependency "example-gem", "~> 1.0"
|
|
50
|
+
|
|
51
|
+
# For more information and examples about making a new gem, check out our
|
|
52
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
|
53
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lru_redux_store
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0.alpha
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- jonathan schatz
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: appraisal
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 2.5.0
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 2.5.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: benchmark
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: bundler
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 2.4.18
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 2.4.18
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: pry-byebug
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 13.4.0
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: 13.4.0
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rspec
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 3.13.0
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: 3.13.0
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: rspec-rails
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: 8.0.2
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: 8.0.2
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: rubocop
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: 1.88.2
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: 1.88.2
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: rubocop-performance
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - "~>"
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: 1.26.0
|
|
131
|
+
type: :development
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - "~>"
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: 1.26.0
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: rubocop-rails
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: 2.35.5
|
|
145
|
+
type: :development
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: 2.35.5
|
|
152
|
+
- !ruby/object:Gem::Dependency
|
|
153
|
+
name: rubocop-rspec
|
|
154
|
+
requirement: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - "~>"
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: 3.10.2
|
|
159
|
+
type: :development
|
|
160
|
+
prerelease: false
|
|
161
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
162
|
+
requirements:
|
|
163
|
+
- - "~>"
|
|
164
|
+
- !ruby/object:Gem::Version
|
|
165
|
+
version: 3.10.2
|
|
166
|
+
- !ruby/object:Gem::Dependency
|
|
167
|
+
name: activesupport
|
|
168
|
+
requirement: !ruby/object:Gem::Requirement
|
|
169
|
+
requirements:
|
|
170
|
+
- - ">="
|
|
171
|
+
- !ruby/object:Gem::Version
|
|
172
|
+
version: 7.2.0
|
|
173
|
+
type: :runtime
|
|
174
|
+
prerelease: false
|
|
175
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
176
|
+
requirements:
|
|
177
|
+
- - ">="
|
|
178
|
+
- !ruby/object:Gem::Version
|
|
179
|
+
version: 7.2.0
|
|
180
|
+
- !ruby/object:Gem::Dependency
|
|
181
|
+
name: sin_lru_redux
|
|
182
|
+
requirement: !ruby/object:Gem::Requirement
|
|
183
|
+
requirements:
|
|
184
|
+
- - ">="
|
|
185
|
+
- !ruby/object:Gem::Version
|
|
186
|
+
version: 2.5.2
|
|
187
|
+
type: :runtime
|
|
188
|
+
prerelease: false
|
|
189
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
190
|
+
requirements:
|
|
191
|
+
- - ">="
|
|
192
|
+
- !ruby/object:Gem::Version
|
|
193
|
+
version: 2.5.2
|
|
194
|
+
- !ruby/object:Gem::Dependency
|
|
195
|
+
name: zeitwerk
|
|
196
|
+
requirement: !ruby/object:Gem::Requirement
|
|
197
|
+
requirements:
|
|
198
|
+
- - ">="
|
|
199
|
+
- !ruby/object:Gem::Version
|
|
200
|
+
version: 2.5.0
|
|
201
|
+
type: :runtime
|
|
202
|
+
prerelease: false
|
|
203
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
204
|
+
requirements:
|
|
205
|
+
- - ">="
|
|
206
|
+
- !ruby/object:Gem::Version
|
|
207
|
+
version: 2.5.0
|
|
208
|
+
description: A bounded, thread-safe, in-memory cache store for Rails. Backed by sin_lru_redux
|
|
209
|
+
for O(1) LRU eviction with optional TTL, it stores raw values with no serialization
|
|
210
|
+
and emits the same interface as ActiveSupport::Cache::Store.
|
|
211
|
+
email:
|
|
212
|
+
- modosc@users.noreply.github.com
|
|
213
|
+
executables: []
|
|
214
|
+
extensions: []
|
|
215
|
+
extra_rdoc_files: []
|
|
216
|
+
files:
|
|
217
|
+
- ".gitignore"
|
|
218
|
+
- ".rspec"
|
|
219
|
+
- ".rubocop.yml"
|
|
220
|
+
- ".rubocop_todo.yml"
|
|
221
|
+
- ".ruby-version"
|
|
222
|
+
- Appraisals
|
|
223
|
+
- Gemfile
|
|
224
|
+
- LICENSE
|
|
225
|
+
- README.md
|
|
226
|
+
- Rakefile
|
|
227
|
+
- bench/README.md
|
|
228
|
+
- bench/bench.rb
|
|
229
|
+
- bench/bench_prune.rb
|
|
230
|
+
- bench/bench_prune_ttl.rb
|
|
231
|
+
- bench/bench_ttl.rb
|
|
232
|
+
- bin/console
|
|
233
|
+
- bin/setup
|
|
234
|
+
- lib/active_support/cache/lru_redux_store.rb
|
|
235
|
+
- lib/lru_redux_store.rb
|
|
236
|
+
- lib/lru_redux_store/dup_coder.rb
|
|
237
|
+
- lib/lru_redux_store/store.rb
|
|
238
|
+
- lib/lru_redux_store/version.rb
|
|
239
|
+
- lru_redux_store.gemspec
|
|
240
|
+
- sig/lru_redux_store.rbs
|
|
241
|
+
homepage: https://github.com/modosc/lru_redux_store
|
|
242
|
+
licenses: []
|
|
243
|
+
metadata:
|
|
244
|
+
allowed_push_host: https://rubygems.org
|
|
245
|
+
homepage_uri: https://github.com/modosc/lru_redux_store
|
|
246
|
+
source_code_uri: https://github.com/modosc/lru_redux_store
|
|
247
|
+
rubygems_mfa_required: 'true'
|
|
248
|
+
licenses: MIT
|
|
249
|
+
rdoc_options: []
|
|
250
|
+
require_paths:
|
|
251
|
+
- lib
|
|
252
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
253
|
+
requirements:
|
|
254
|
+
- - ">="
|
|
255
|
+
- !ruby/object:Gem::Version
|
|
256
|
+
version: 3.3.0
|
|
257
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
258
|
+
requirements:
|
|
259
|
+
- - ">="
|
|
260
|
+
- !ruby/object:Gem::Version
|
|
261
|
+
version: '0'
|
|
262
|
+
requirements: []
|
|
263
|
+
rubygems_version: 4.0.12
|
|
264
|
+
specification_version: 4
|
|
265
|
+
summary: An ActiveSupport::Cache::Store backed by sin_lru_redux.
|
|
266
|
+
test_files: []
|