rimcache 0.1.0

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: 8193c5feead4d6c058e64ba03b94fa863e32d5c9ab29819e3d44458401204914
4
+ data.tar.gz: 0bcf98dddfe53da75d045cfbd7870e4978b1b793c4f701556871ee04b5652b5c
5
+ SHA512:
6
+ metadata.gz: 6226294aca56945946737f0feb4555b3e4d09ae46bba2faebdd9d71afd0afb5b2d4ffd37214fb24eb5d9b8d705f019cf44b7ffc35b2f0bb583607f4b4ace2f60
7
+ data.tar.gz: 7a1b36e38741f6716de88d51b8dc39f664bea0fac47c4dfa818559a18d9bdcb5e6a3ad65dbe8611d4b0ab4eba0065279977b74713a6655ea98fda70fb379459f
data/.rubocop.yml ADDED
@@ -0,0 +1,24 @@
1
+ plugins:
2
+ - rubocop-minitest
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 3.0
6
+ NewCops: enable
7
+
8
+ Style/StringLiterals:
9
+ Enabled: true
10
+ EnforcedStyle: double_quotes
11
+
12
+ Style/StringLiteralsInInterpolation:
13
+ Enabled: true
14
+ EnforcedStyle: double_quotes
15
+
16
+ Layout/LineLength:
17
+ Max: 120
18
+
19
+ Metrics/AbcSize:
20
+ Exclude:
21
+ - 'test/**/*'
22
+
23
+ Gemspec/RequireMFA:
24
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-02-06
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Duncan McKee
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,129 @@
1
+ # Rimcache
2
+
3
+ A right-in-memory "cache" for Ruby on Rails.
4
+
5
+ Does your Rails app have individual records that most or all of your requests use?
6
+ They rarely change but it's still handy to have them in the database?
7
+
8
+ With Rimcache, you can keep a frozen ActiveRecord object or set of objects in
9
+ memory and it will automatically refresh from the database when any server worker
10
+ invalidates it. It avoids the serialization required for caching in Redis/Memcache,
11
+ so it is usable for situations where Rails low-level caching is not.
12
+ Associations on a record will stay loaded, so it can save more than one query per
13
+ cached record.
14
+
15
+
16
+ ## Usage
17
+
18
+ Say you have an app where every request is processed in the context one of just a few `Site` records.
19
+ You could avoid loading it from the database every time like this:
20
+
21
+ ```ruby
22
+ class Site < ActiveRecord::Base
23
+ has_many :features
24
+
25
+ after_update :expire_rimcache
26
+
27
+ def self.find_cached(slug)
28
+ Rimcache.fetch("site_#{slug}") do
29
+ # This will only be called if site is not already in cache or was expired
30
+ Site.eager_load(:features).find_by(slug:)
31
+ end
32
+ end
33
+
34
+ def expire_rimcache
35
+ Rimcache.expire("site_#{slug}")
36
+ end
37
+ end
38
+ ```
39
+
40
+
41
+ ## Configuration
42
+
43
+ If the record is rarely updated and you'd rather not bother with expiration,
44
+ you can set a TTL with `Rimcache.config.refresh_interval`:
45
+
46
+ ```ruby
47
+ Rimcache.config.refresh_interval = 15.minutes
48
+ ```
49
+
50
+ This will cause the cache to be refreshed every 15 minutes, even if it has not been explicitly expired.
51
+ That way, any changes to the record will be reflected by other processes in at most 15 minutes.
52
+ Keep in mind, however, that using this strategy will lead to inconsistency between processes as they
53
+ won't all reflect the changes at the same time.
54
+
55
+ When you want to edit the record, you can call `Rimcache.fetch` with the `for_update` keyword argument:
56
+
57
+ ```ruby
58
+ class Site < ActiveRecord::Base
59
+ has_many :features
60
+
61
+ after_update :expire_rimcache
62
+
63
+ def self.fetch_by_slug(slug, for_update: false)
64
+ Rimcache.fetch("site_#{slug}", for_update:) do
65
+ # This will only be called if site is not already in cache or was expired
66
+ Site.eager_load(:features).find_by(slug:)
67
+ end
68
+ end
69
+
70
+ def expire_rimcache
71
+ Rimcache.expire("site_#{slug}")
72
+ end
73
+ end
74
+ ```
75
+
76
+ Then a Site can be updated like this:
77
+
78
+ ```ruby
79
+ site = Site.fetch_by_slug(:example, for_update: true)
80
+ site.update!(name: "New Name") # after_update callback expires the rimcache
81
+ ```
82
+
83
+ Note that `for_update` does not handle expiration, it just ensures the record is refreshed
84
+ from the database and not frozen.
85
+
86
+
87
+ ### Expiry cache
88
+
89
+ Rimcache by default uses Rails' low-level caching to coordinate expiration across processes.
90
+ If you want to use a different cache store, you can set `Rimcache.config.expiry_cache` to an instance of
91
+ `ActiveSupport::Cache::Store`.
92
+
93
+ ```ruby
94
+ Rimcache.config.expiry_cache # defaults to Rails.cache
95
+ Rimcache.config.expiry_cache = ActiveSupport::Cache::MemoryStore.new
96
+ ```
97
+
98
+ If you want to use the same cache store as Rails but avoid conflicts, such as with
99
+ another instance of `Rimcache::Store`, you can change `Rimcache.config.expiry_cache_key`:
100
+
101
+ ```ruby
102
+ alt_rimcache = Rimcache::Store.new
103
+ alt_rimcache.config.expiry_cache_key = "alt_rimcache"
104
+ ```
105
+
106
+ In order to reduce the number of hits to the expiry cache, Rimcache only checks it
107
+ every 4 seconds by default. This can be changed with `Rimcache.config.check_frequency`:
108
+
109
+ ```ruby
110
+ Rimcache.config.check_frequency = 30.seconds
111
+ Rimcache.config.check_frequency = nil # always check the expiry cache
112
+ ```
113
+
114
+
115
+ <!--
116
+ ## Development
117
+
118
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
119
+
120
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
121
+ -->
122
+
123
+ ## Contributing
124
+
125
+ Bug reports and pull requests are welcome on GitHub at https://github.com/mckeed/rimcache.
126
+
127
+ ## License
128
+
129
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/README.rdoc ADDED
@@ -0,0 +1,12 @@
1
+ = Rimcache - the Right-in-Memory Cache
2
+
3
+ "Caches" your commonly-used objects by just storing them frozen in a global hash.
4
+
5
+ Rimcache.cache(:default_settings) do
6
+ Settings.default
7
+ end
8
+
9
+ It avoids a round-trip to Redis/memcache and serialization, so associations will stay loaded.
10
+
11
+ Expiration is coordinated between processes via `Rails.cache` or a configurable ActiveSupport::Cache::Store.
12
+ See +Rimcache::Config+ for configuration options.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "active_support/all"
5
+
6
+ module Rimcache
7
+ # Configuration for Rimcache
8
+ class Config
9
+ # An instance of ActiveSupport::Cache::Store used to coordinate Rimcache expiry and invalidation
10
+ # across processes. If not set, defaults to Rails.cache.
11
+ # @return [ActiveSupport::Cache::Store]
12
+ attr_accessor :expiry_cache
13
+
14
+ # The cache key to use in expiry_cache.
15
+ # Defaults to "rimcache_expiry" but can be changed to avoid conflicts.
16
+ # @return [String | Symbol]
17
+ attr_accessor :expiry_cache_key
18
+
19
+ # The expiry cache will only be checked if it has not been in this much time.
20
+ # A stale cached value can keep being returned for up to this long after another
21
+ # process invalidates it.
22
+ # Defaults to 4 seconds
23
+ # @return [ActiveSupport::Duration] time in seconds
24
+ attr_accessor :check_frequency
25
+
26
+ # If this is set, rimcached values will be refreshed after this much
27
+ # time even if they have not been explicitly expired.
28
+ # Default value is +nil+.
29
+ # @return [ActiveSupport::Duration] time in seconds
30
+ attr_accessor :refresh_interval
31
+
32
+ def initialize
33
+ @check_frequency = 4.seconds
34
+ @expiry_cache_key = "rimcache_expiry"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require "active_support/all"
5
+
6
+ module Rimcache
7
+ # The core of Rimcache. Stores frozen objects in a Hash while tracking when
8
+ # they were last stored and when they were last updated by any process connected
9
+ # to the same cross-process expiry cache.
10
+ #
11
+ # Access this via Rimcache.store for a singleton instance.
12
+ #
13
+ class Store
14
+ attr_accessor :config, :cached
15
+
16
+ def initialize
17
+ @config = Config.new
18
+ @cached = {}
19
+ @written_at = {}
20
+ @last_check = Time.current
21
+
22
+ # @expiry_cache is set on first demand to give the best chance
23
+ # that Rails.cache is loaded when we try to access it
24
+ @expiry_cache = nil
25
+ @expiry = nil
26
+ end
27
+
28
+ # Returns the value cached under +key+
29
+ def read(key)
30
+ cached[key]
31
+ end
32
+
33
+ # Returns true if no value has been cached for +key+ yet, if +expire+ has been
34
+ # called from any process with access to the same +expiry_cache+
35
+ # @return [Boolean] Whether the value under +key+ needs to be refreshed.
36
+ def expired?(key)
37
+ last_update = @written_at[key]
38
+ return true if !cached[key] || needs_refresh?(last_update)
39
+
40
+ # Check if another process has changed the value since we read it
41
+ expired_at = reload_expiry[key]
42
+ !!expired_at && expired_at > last_update
43
+ end
44
+
45
+ # Update the expiry_cache to notify other processes that the
46
+ # rimcached object at this key has changed and must be refreshed.
47
+ def expire(key)
48
+ cached[key] = nil
49
+ reload_expiry(force: true)
50
+ @expiry[key] = Time.current
51
+ expiry_cache.write(config.expiry_cache_key, @expiry, expires_in: nil)
52
+ end
53
+
54
+ # Fetches or updates data from the rimcache at the given key.
55
+ # If there's an unexpired stored value it is returned. Otherwise,
56
+ # the block is called and the result is frozen, stored in the rimcache
57
+ # under this key, and returned.
58
+ #
59
+ # The keyword argument +for_update+ can be set to +true+ to call the block
60
+ # and return the result unfrozen instead of caching it. You must still call
61
+ # +expire(key)+ or +update(key, value)+ after updating the record in the database.
62
+ #
63
+ def fetch(key, for_update: false)
64
+ raise(ArgumentError, "Rimcache: tried to store to cache with nil key") if key.nil?
65
+
66
+ if for_update
67
+ cached[key] = nil
68
+ yield
69
+ elsif expired?(key) || !cached[key]
70
+ write(key, yield.freeze) if block_given?
71
+ else
72
+ read(key)
73
+ end
74
+ end
75
+
76
+ # Expires the rimcache for this key and saves a frozen clone of +value+
77
+ # as the new value.
78
+ def update(key, value)
79
+ expire(key)
80
+ write(key, value.clone.freeze)
81
+ end
82
+
83
+ private
84
+
85
+ def needs_refresh?(last_update)
86
+ return true if last_update.nil?
87
+
88
+ !config.refresh_interval.nil? && Time.current - last_update > config.refresh_interval
89
+ end
90
+
91
+ def reload_expiry(force: false)
92
+ # Only check expiry_cache every few seconds for excessive efficiency
93
+ now = Time.current
94
+ return @expiry unless force || @expiry.nil? || now - @last_check > config.check_frequency
95
+
96
+ @last_check = now
97
+ @expiry = expiry_cache.read(config.expiry_cache_key) || {}
98
+ end
99
+
100
+ def write(key, value)
101
+ @written_at[key] = Time.current
102
+ cached[key] = value # must return value
103
+ end
104
+
105
+ # @return [ActiveSupport::Cache::Store]
106
+ def expiry_cache
107
+ @expiry_cache ||= config.expiry_cache || rails_cache || fallback_cache
108
+ end
109
+
110
+ def rails_cache
111
+ require "rails"
112
+ Rails.cache
113
+ rescue LoadError, NoMethodError
114
+ # return nil if there's no rails or no Rails.cache
115
+ end
116
+
117
+ def fallback_cache
118
+ puts "[Rimcache] Warning: no expiry cache found. Rimcache will not be invalidated across multiple processes."
119
+ ActiveSupport::Cache::MemoryStore.new
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rimcache
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rimcache.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rimcache/store"
4
+ require_relative "rimcache/version"
5
+
6
+ # The main entry point to Rimcache.
7
+ # "Caches" commonly-used objects by just storing them frozen in a global hash.
8
+ # Expiration is handled via Rails low-level caching or a configurable ActiveSupport::Cache::Store.
9
+ #
10
+ module Rimcache
11
+ # A singleton instance of +Rimcache::Store+.
12
+ def self.store
13
+ @store ||= Store.include(Singleton).instance
14
+ end
15
+
16
+ # The +Rimcache::Config+ object associated with the singleton +store+
17
+ def self.config
18
+ store.config
19
+ end
20
+
21
+ # Fetches or updates data from the rimcache at the given key.
22
+ # If there's an unexpired stored value it is returned. Otherwise,
23
+ # the block is called and the result is frozen, stored in the rimcache
24
+ # under this key, and returned.
25
+ #
26
+ # The keyword argument +for_update+ can be set to +true+ to call the block
27
+ # and return the result unfrozen instead of caching it. You must still call
28
+ # +expire(key)+ or +update(key, value)+ after updating the record in the database.
29
+ #
30
+ def self.fetch(...)
31
+ store.fetch(...)
32
+ end
33
+
34
+ def self.expire(...)
35
+ store.expire(...)
36
+ end
37
+
38
+ module_function
39
+
40
+ # You can include +Rimcache+ in your model to use +rimcache+ instead of +Rimcache.fetch+
41
+ def rimcache(...)
42
+ Rimcache.fetch(...)
43
+ end
44
+
45
+ # You can include +Rimcache+ in your model to use +rimcache_expire+ instead of +Rimcache.expire+
46
+ def rimcache_expire(...)
47
+ Rimcache.expire(...)
48
+ end
49
+ end
data/sig/rimcache.rbs ADDED
@@ -0,0 +1,27 @@
1
+ # The main entry point to Rimcache.
2
+ # "Caches" commonly-used objects by just storing them frozen in a global hash.
3
+ # Expiration is handled via Rails low-level caching or a configurable ActiveSupport::Cache::Store.
4
+ #
5
+ module Rimcache
6
+ self.@store: untyped
7
+
8
+ # A singleton instance of +Rimcache::Store+.
9
+ def self.store: () -> Store
10
+
11
+ # The +Rimcache::Config+ object associated with the singleton +store+
12
+ def self.config: () -> Config
13
+
14
+ # Fetches or updates data from the rimcache at the given key.
15
+ # If there's an unexpired stored value it is returned. Otherwise,
16
+ # the block is called and the result is frozen, stored in the rimcache
17
+ # under this key, and returned.
18
+ #
19
+ # The keyword argument +for_update+ can be set to +true+ to call the block
20
+ # and return the result unfrozen instead of caching it. You must still call
21
+ # +expire(key)+ or +update(key, value)+ after updating the record in the database.
22
+ #
23
+ def self.fetch: ((String | Symbol | Object) key, ?for_update: bool) { (void) -> T } -> T
24
+
25
+ # You can include +Rimcache+ in your model to use +rimcache+ instead of +Rimcache.fetch+
26
+ def self?.rimcache: ((String | Symbol | Object) key, ?for_update: bool) { (void) -> T } -> T
27
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rimcache
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Duncan McKee
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-05-29 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">"
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">"
24
+ - !ruby/object:Gem::Version
25
+ version: '6.1'
26
+ description: |
27
+ "Caches" commonly-used objects by just storing them frozen in a global hash.
28
+ Expiration is handled via Rails low-level caching or an ActiveSupport::Cache
29
+ email:
30
+ - mckeed+rubygems@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".rubocop.yml"
36
+ - CHANGELOG.md
37
+ - LICENSE.txt
38
+ - README.md
39
+ - README.rdoc
40
+ - Rakefile
41
+ - lib/rimcache.rb
42
+ - lib/rimcache/config.rb
43
+ - lib/rimcache/store.rb
44
+ - lib/rimcache/version.rb
45
+ - sig/rimcache.rbs
46
+ homepage: https://github.com/mckeed/rimcache
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://github.com/mckeed/rimcache
51
+ source_code_uri: https://github.com/mckeed/rimcache
52
+ changelog_uri: https://github.com/mckeed/rimcache/tree/CHANGELOG.md
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 3.0.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.6.2
68
+ specification_version: 4
69
+ summary: Keep commonly-used records in memory with expiration coordinated via the
70
+ Rails cache.
71
+ test_files: []