activerecord_cached 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 051daa89ebda43ea098a7f152e567730f1a71f4794919ac4ac3ca8ec98936526
4
+ data.tar.gz: 829ea885b15719e2c77357f4caa1c6d39d4c221719fcca39a295eb41af1e0256
5
+ SHA512:
6
+ metadata.gz: d98b20830828b808644df5b31a6a917a40af2aba50808e003c4b2b92d08f1b27297ba140ae61a4ea82c7c8c27ac0927545576734cf2b5f05074556d4daedcadf
7
+ data.tar.gz: ba34d2b20e9f07d13eb40b4bee773ed048eea9546d78e298b1ec0aa3c67c8012c170dad502b1d226d1ed3f50de54e12623f8abd6fa79d404d3e560fe2907990e
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [1.0.0] - 2024-01-23
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Janosch Müller
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,64 @@
1
+ # ActiveRecordCached
2
+
3
+ This gem adds methods to cache small amounts of ActiveRecord data in RAM, Redis, or other stores.
4
+
5
+ The cache for each model is busted both by individual CRUD operations on that model (e.g. `#update`), as well as by mass operations (e.g. `#update_all`).
6
+
7
+ The cached methods work on whole models as well as on relations.
8
+
9
+ There is an automatic warning if the data is getting too big to cache.
10
+
11
+ ## Installation
12
+
13
+ Install the gem and add to the application's Gemfile by executing:
14
+
15
+ $ bundle add activerecord_cached
16
+
17
+ ## Usage
18
+
19
+ ```ruby
20
+ Pizza.cached_count # => 2374
21
+ Pizza.limit(2).cached_pluck(:name) # => ["Funghi", "Spinaci"]
22
+ Pizza.select(:id, :name).cached_records # => [#<Pizza id=1 name="Funghi">, ...]
23
+ ```
24
+
25
+ Configuration:
26
+
27
+ ```ruby
28
+ ActiveRecordCached.configure do |config|
29
+ # How to cache the data. Default: MemoryStore
30
+ config.cache_store = Rails.cache
31
+
32
+ # Maximum size of the standard memory store. Default: 32MB
33
+ config.max_total_bytes = 16.megabytes
34
+
35
+ # How much data to allow per cached relation. Default: 1MB
36
+ config.max_bytes = 2.megabytes
37
+
38
+ # How many records to allow per cached relation. Default: 10k
39
+ config.max_count = 1000
40
+
41
+ # What do to when any limit is exceeded. Default: warn
42
+ config.on_limit_reached = ->msg{ report(msg) }
43
+ end
44
+ ```
45
+
46
+ Specs:
47
+
48
+ ```ruby
49
+ RSpec.configure do |config|
50
+ config.after(:each) { ActiveRecordCached.clear_all }
51
+ end
52
+ ```
53
+
54
+ ## Tradeoffs
55
+
56
+ The default MemoryStore is orders of magnitude faster than e.g. Redis, but it is wiped when the app is deployed or otherwise restarted, which may present a [cache stampede](https://en.wikipedia.org/wiki/Cache_stampede) risk under very high load.
57
+
58
+ ## Contributing
59
+
60
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jaynetics/activerecord_cached.
61
+
62
+ ## License
63
+
64
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,54 @@
1
+ # define cached_* methods, e.g. cached_pluck
2
+ module ActiveRecordCached
3
+ module BaseExtension
4
+ def clear_cached_values
5
+ ActiveRecordCached.clear_for_model(self)
6
+ end
7
+ end
8
+
9
+ module RelationExtension
10
+ def clear_cached_values
11
+ ActiveRecordCached.clear_for_model(klass)
12
+ end
13
+ end
14
+
15
+ %i[count maximum minimum pick pluck records sum].each do |method|
16
+ BaseExtension.class_eval <<~RUBY, __FILE__, __LINE__ + 1
17
+ def cached_#{method}(*args)
18
+ all.cached_#{method}(*args)
19
+ end
20
+ RUBY
21
+
22
+ RelationExtension.class_eval <<~RUBY, __FILE__, __LINE__ + 1
23
+ def cached_#{method}(*args)
24
+ ActiveRecordCached.fetch(self, :#{method}, args)
25
+ end
26
+ RUBY
27
+ end
28
+
29
+ ActiveRecord::Base.singleton_class.prepend BaseExtension
30
+ ActiveRecord::Relation.prepend RelationExtension
31
+
32
+ # bust cache on individual record changes - this module is included
33
+ # automatically into models that use cached methods.
34
+ module CRUDCallbacks
35
+ def self.included(base)
36
+ base.after_commit { self.class.clear_cached_values }
37
+ end
38
+ end
39
+
40
+ # bust cache on mass operations
41
+ module MassOperationWrapper
42
+ %i[delete_all insert_all touch_all update_all update_counters upsert_all].each do |mass_op|
43
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
44
+ def #{mass_op}(...)
45
+ result = super(...)
46
+ result && clear_cached_values
47
+ result
48
+ end
49
+ RUBY
50
+ end
51
+ end
52
+ ActiveRecord::Base.singleton_class.prepend MassOperationWrapper
53
+ ActiveRecord::Relation.prepend MassOperationWrapper
54
+ end
@@ -0,0 +1,57 @@
1
+ module ActiveRecordCached
2
+ def fetch(relation, method, args)
3
+ key = ['ActiveRecordCached', relation.to_sql, method, args.sort].join(':')
4
+ model = relation.klass
5
+ prepare(model)
6
+ cache_store.fetch(key) do
7
+ log_model_cache_key(model, key)
8
+ query_db(relation, method, args)
9
+ end
10
+ end
11
+
12
+ def clear_for_model(model)
13
+ keys = cache_keys_per_model
14
+ return unless model_keys = keys.delete(model)&.keys
15
+
16
+ cache_store.delete_multi(model_keys)
17
+ cache_store.write(CACHE_KEYS_KEY, keys)
18
+ end
19
+
20
+ def clear_all
21
+ all_keys = cache_keys_per_model.values.flat_map(&:keys)
22
+ cache_store.delete_multi(all_keys)
23
+ cache_store.delete(CACHE_KEYS_KEY)
24
+ end
25
+
26
+ private
27
+
28
+ def prepare(model)
29
+ return if CRUDCallbacks.in?(model.included_modules)
30
+
31
+ PREPARE_MUTEX.synchronize do
32
+ CRUDCallbacks.in?(model.included_modules) || model.include(CRUDCallbacks)
33
+ end
34
+ end
35
+
36
+ PREPARE_MUTEX = Mutex.new
37
+
38
+ def log_model_cache_key(model, key)
39
+ keys = cache_keys_per_model
40
+ (keys[model] ||= {})[key] = true
41
+ cache_store.write(CACHE_KEYS_KEY, keys)
42
+ end
43
+
44
+ def cache_keys_per_model
45
+ cache_store.read(CACHE_KEYS_KEY) || {}
46
+ end
47
+
48
+ CACHE_KEYS_KEY = 'ActiveRecordCached:cache_keys_per_model'
49
+
50
+ def query_db(rel, method, args)
51
+ rel = rel.limit(max_count) if max_count && rel.limit_value.nil?
52
+ result = rel.send(method, *args)
53
+ result = result.to_a if method == :select
54
+ check_limit(rel, method, result)
55
+ result
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ module ActiveRecordCached
2
+ def configure(&block)
3
+ tap(&block)
4
+ end
5
+
6
+ def cache_store
7
+ @cache_store ||= store_with_limit_warning(
8
+ ActiveSupport::Cache::MemoryStore.new({ coder: nil, size: max_total_bytes })
9
+ )
10
+ end
11
+
12
+ def cache_store=(val)
13
+ val.is_a?(ActiveSupport::Cache::Store) || raise(ArgumentError, 'pass an ActiveSupport::Cache::Store')
14
+ @cache_store = store_with_limit_warning(val)
15
+ end
16
+
17
+ mattr_accessor(:max_total_bytes) { 32.megabytes }
18
+ def max_total_bytes=(val)
19
+ val.is_a?(Integer) && val > 0 || raise(ArgumentError, 'pass an int > 0')
20
+ super
21
+ end
22
+
23
+ mattr_accessor(:max_count) { 10_000 }
24
+ def max_count=(val)
25
+ val.is_a?(Integer) && val > 0 || val.nil? || raise(ArgumentError, 'pass an int > 0 or nil')
26
+ super
27
+ end
28
+
29
+ mattr_accessor(:max_bytes) { 1.megabyte }
30
+ def max_bytes=(val)
31
+ val.is_a?(Integer) && val > 0 || val.nil? || raise(ArgumentError, 'pass an int > 0 or nil')
32
+ super
33
+ end
34
+
35
+ mattr_accessor(:on_limit_reached) { ->(msg) { warn(msg) } }
36
+ def on_limit_reached=(val)
37
+ val.is_a?(Proc) && val.arity == 1 || raise(ArgumentError, 'pass a proc with arity 1')
38
+ super
39
+ end
40
+
41
+ private
42
+
43
+ def store_with_limit_warning(store)
44
+ return store unless store.is_a?(ActiveSupport::Cache::MemoryStore)
45
+
46
+ store.singleton_class.prepend(Module.new do
47
+ def prune(...)
48
+ ActiveRecordCached.send(:warn_max_total_bytes_exceeded)
49
+ super
50
+ end
51
+ end)
52
+ store
53
+ end
54
+ end
@@ -0,0 +1,29 @@
1
+ module ActiveRecordCached
2
+ private
3
+
4
+ def check_limit(relation, method, result)
5
+ values = Array(result)
6
+ if values.count == max_count
7
+ warn_limit_reached("#{relation.klass}.#{method} >= #{max_count} max_count")
8
+ elsif max_bytes_exceeded?(values)
9
+ warn_limit_reached("#{relation.klass}.#{method} >= #{max_bytes} max_bytes")
10
+ end
11
+ end
12
+
13
+ def warn_max_total_bytes_exceeded
14
+ warn_limit_reached("Store size >= #{max_total_bytes} max_total_bytes")
15
+ end
16
+
17
+ def warn_limit_reached(info)
18
+ on_limit_reached.call("ActiveRecordCached: data getting too big to cache. #{info}")
19
+ end
20
+
21
+ def max_bytes_exceeded?(values)
22
+ memsize = 0
23
+ max_bytes && values.inject(0) do |value|
24
+ memsize += Marshal.dump(value).bytesize
25
+ return true if memsize >= max_bytes
26
+ end
27
+ false
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ module ActiveRecordCached
2
+ class Railtie < ::Rails::Railtie
3
+ config.to_prepare do
4
+ ActiveRecordCached.clear_all
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordCached
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support"
5
+
6
+ require_relative "activerecord_cached/activerecord_extensions"
7
+ require_relative "activerecord_cached/cache"
8
+ require_relative "activerecord_cached/configuration"
9
+ require_relative "activerecord_cached/limit_checks"
10
+ require_relative "activerecord_cached/railtie" if defined?(::Rails::Railtie)
11
+ require_relative "activerecord_cached/version"
12
+
13
+ module ActiveRecordCached
14
+ extend self
15
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord_cached
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Janosch Müller
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-01-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ description:
42
+ email:
43
+ - janosch84@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rspec"
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - Rakefile
53
+ - lib/activerecord_cached.rb
54
+ - lib/activerecord_cached/activerecord_extensions.rb
55
+ - lib/activerecord_cached/cache.rb
56
+ - lib/activerecord_cached/configuration.rb
57
+ - lib/activerecord_cached/limit_checks.rb
58
+ - lib/activerecord_cached/railtie.rb
59
+ - lib/activerecord_cached/version.rb
60
+ homepage: https://github.com/jaynetics/activerecord_cached
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ homepage_uri: https://github.com/jaynetics/activerecord_cached
65
+ source_code_uri: https://github.com/jaynetics/activerecord_cached
66
+ changelog_uri: https://github.com/jaynetics/activerecord_cached/blob/main/CHANGELOG.md
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 3.0.0
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.5.0.dev
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: Flexibly cache ActiveRecord queries across requests
86
+ test_files: []