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 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
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ Gemfile.lock
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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in lru_redux_store.gemspec
6
+ gemspec
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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
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)
@@ -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,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,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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LruReduxStore
4
+ # Current release version.
5
+ VERSION = '0.1.0.alpha'
6
+ 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
@@ -0,0 +1,4 @@
1
+ module LruReduxStore
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ 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: []