bulk_cache_fetcher 0.0.1

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
+ SHA1:
3
+ metadata.gz: 46f577da5b738735691d165c968cd243f2fff412
4
+ data.tar.gz: 187e82b0dd313cba27e24bb77d3323af8cc2f5b6
5
+ SHA512:
6
+ metadata.gz: fa9a8f78afd53293b374db43a7a8631d4d21d0ec921752da726888ae455a3c33d46236352c5834cbe5b3d4c37806819ac465cb27a1ec9d7a857508bc0333d473
7
+ data.tar.gz: 92ec07ef649d39565508bbd98f94bb9c26f1a441e38f747d7a68133ce64bc29721703c9c65a8d19326d847a31d1de2c8f31372604d10faf4361bcd774bfa0f64
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in bulk_cache_fetcher.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Justin Weiss
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # BulkCacheFetcher
2
+
3
+ Bulk Cache Fetcher fills the gap between [Russian doll caching](http://37signals.com/svn/posts/3113-how-key-based-cache-expiration-works) and the n+1 queries problem.
4
+
5
+ Russian doll caching is really great for handling views and
6
+ partials. When those partials show highly nested objects, though,
7
+ cache misses are expensive. Usually, you'll either preload the entire
8
+ object hierarchies in your controller (even on cache hits), or you'll
9
+ accept the n+1 queries when you miss the cache.
10
+
11
+ Bulk Cache Fetcher allows you to query the cache for a list of
12
+ objects, and gives you the opportunity to fetch all of the cache
13
+ misses at once, using whatever `:include`s you want.
14
+
15
+ ## Installation
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ gem 'bulk_cache_fetcher'
20
+
21
+ And then execute:
22
+
23
+ $ bundle
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install bulk_cache_fetcher
28
+
29
+ ## Usage
30
+
31
+ Basic usage is pretty simple:
32
+
33
+ ```ruby
34
+ cache_fetcher = BulkCacheFetcher.new(Rails.cache)
35
+ cache_fetcher.fetch([1, 2, 3]) do |identifiers|
36
+ Post.includes(...).find(identifiers)
37
+ end
38
+ ```
39
+
40
+ it even returns them in the same order you return them in:
41
+
42
+ ```ruby
43
+ results = cache_fetcher.fetch([2, 1, 3]) do |identifiers|
44
+ expensive_calculation(identifiers) # => returns [result 2, result 1, result 3]
45
+ end
46
+
47
+ results.first # => expensive_calculation([2])
48
+ cache.read(1) # => expensive_calculation([1])
49
+ ```
50
+
51
+ ### Complex identifiers
52
+
53
+ In a lot of cases, you'll have a cache key along with a little bit of
54
+ extra data you'll need to find the record or perform a
55
+ calculation. You can use the cache fetcher for this, with a hash
56
+ instead of an array for the identifier list:
57
+
58
+ ```ruby
59
+ cache_fetcher.fetch({:one => 1, :two => 2}) do |identifiers|
60
+ expensive_calculation(identifiers.values)
61
+ end
62
+
63
+ cache.read(:one) # => expensive_calculation([1])
64
+ ```
65
+
66
+ ## Contributing
67
+
68
+ 1. Fork it
69
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
70
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
71
+ 4. Push to the branch (`git push origin my-new-feature`)
72
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+ require "rdoc/task"
4
+
5
+ task :default => :test
6
+
7
+ Rake::TestTask.new do |t|
8
+ t.libs << "test"
9
+ t.test_files = FileList['test/*_test.rb']
10
+ end
11
+
12
+ Rake::RDocTask.new do |rd|
13
+ rd.main = "README.md"
14
+ rd.rdoc_files.include("README.md", "lib/**/*.rb")
15
+ rd.rdoc_dir = 'doc'
16
+ end
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'bulk_cache_fetcher'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "bulk_cache_fetcher"
8
+ spec.version = BulkCacheFetcher::VERSION
9
+ spec.authors = ["Justin Weiss"]
10
+ spec.email = ["jweiss@avvo.com"]
11
+ spec.description = %q{Fetches cache misses in bulk}
12
+ spec.summary = %q{Bulk Cache Fetcher allows you to query the cache for a list of
13
+ objects, and gives you the opportunity to fetch all of the cache
14
+ misses at once, using whatever `:include`s you want.}
15
+ spec.homepage = "https://github.com/justinweiss/bulk_cache_fetcher"
16
+ spec.license = "MIT"
17
+
18
+ spec.files = `git ls-files`.split($/)
19
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
20
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.3"
24
+ spec.add_development_dependency "rake"
25
+ spec.add_development_dependency "minitest"
26
+ end
@@ -0,0 +1,99 @@
1
+ # Fetches many objects from a cache in order. In the event that some
2
+ # objects can't be served from the cache, you will have the
3
+ # opportunity to fetch them in bulk. This allows you to preload and
4
+ # cache entire object hierarchies, which works particularly well with
5
+ # Rails' nested caching while avoiding the n+1 queries problem in the
6
+ # uncached case.
7
+ class BulkCacheFetcher
8
+ VERSION = '0.0.1'
9
+
10
+ # Creates a new bulk cache fetcher, backed by +cache+. Cache must
11
+ # respond to the standard Rails cache API, described on
12
+ # http://guides.rubyonrails.org/caching_with_rails.html
13
+ def initialize(cache)
14
+ @cache = cache
15
+ end
16
+
17
+ # Returns a list of objects identified by
18
+ # <tt>object_identifiers</tt>. +fetch+ will try to find the objects
19
+ # from the cache first. Identifiers for objects that aren't in the
20
+ # cache will be passed as an ordered list to <tt>finder_block</tt>,
21
+ # where you can find the objects as you see fit. These objects
22
+ # should be returned in the same order as the identifiers that were
23
+ # passed into the block, because they'll be cached under their
24
+ # respective keys. The objects returned by +fetch+ will be returned
25
+ # in the same order as the <tt>object_identifiers</tt> passed in.
26
+ #
27
+ # +options+ will be passed along unmodified when caching newly found
28
+ # objects, so you can use it for things like setting cache
29
+ # expiration.
30
+ def fetch(object_identifiers, options = {}, &finder_block)
31
+ cached_objects, uncached_identifiers = partition(object_identifiers)
32
+ coalesce(cached_objects, find(uncached_identifiers, options, &finder_block))
33
+ end
34
+
35
+ private
36
+
37
+ # Partitions a list of identifiers into two lists. The first list
38
+ # contains all of the objects we were able to serve from the cache,
39
+ # while the second is a list of all of the identifiers for objects
40
+ # that weren't cached.
41
+ def partition(object_identifiers)
42
+ object_identifiers = normalize(object_identifiers)
43
+ cached_objects = []
44
+ uncached_identifiers = object_identifiers.dup
45
+
46
+ object_identifiers.each do |identifier|
47
+ cache_key = cache_key(identifier)
48
+ cached_object = @cache.read(cache_key)
49
+
50
+ uncached_identifiers.delete(cache_key) if cached_object
51
+ cached_objects << cached_object
52
+ end
53
+
54
+ [cached_objects, uncached_identifiers]
55
+ end
56
+
57
+ # Finds all of the objects identified by +identifiers+, using the
58
+ # +finder_block+. Will pass +options+ on to the cache.
59
+ def find(identifiers, options = {}, &finder_block)
60
+ unless identifiers.empty?
61
+ Array(finder_block.(identifiers)).tap do |objects|
62
+ verify_equal_key_and_value_counts!(identifiers, objects)
63
+ cache_all(identifiers, objects, options)
64
+ end
65
+ end
66
+ end
67
+
68
+ # Makes sure we have enough +identifiers+ to cache all of our
69
+ # +objects+, and vice-versa.
70
+ def verify_equal_key_and_value_counts!(identifiers, objects)
71
+ raise ArgumentError, "You are returning too many objects from your cache block!" if objects.length > identifiers.length
72
+ raise ArgumentError, "You are returning too few objects from your cache block!" if objects.length < identifiers.length
73
+ end
74
+
75
+ # Caches all +values+ under their respective +keys+.
76
+ def cache_all(keys, values, options = {})
77
+ keys.zip(values) { |k, v| @cache.write(cache_key(k), v, options) }
78
+ end
79
+
80
+ # With an array looking like <tt>[nil, 1, nil, 2]</tt>, replaces the
81
+ # nils with values taken from +found_objects+, in order.
82
+ def coalesce(array_with_nils, found_objects)
83
+ found_objects = Array(found_objects)
84
+ array_with_nils.map { |obj| obj ? obj : found_objects.shift }
85
+ end
86
+
87
+ # Returns the part of the identifier that we can use as the cache
88
+ # key. For simple identifiers, it's just the identifier, for
89
+ # identifiers with extra information attached, it's the first part
90
+ # of the identifier.
91
+ def cache_key(identifier)
92
+ Array(identifier).first
93
+ end
94
+
95
+ # Makes sure we can iterate over identifiers.
96
+ def normalize(identifiers)
97
+ identifiers.respond_to?(:each) ? identifiers : Array(identifiers)
98
+ end
99
+ end
@@ -0,0 +1,121 @@
1
+ require 'test_helper'
2
+
3
+ class InMemoryCache
4
+ attr_accessor :cache, :options
5
+
6
+ def initialize(options = {})
7
+ @cache = {}
8
+ @options = {}
9
+ end
10
+
11
+ def read(key); cache[key]; end
12
+ def write(key, value, opts = {}); options[key] = opts; cache[key] = value; end
13
+ end
14
+
15
+ class BulkCacheFetcherTest < Minitest::Unit::TestCase
16
+
17
+ def setup
18
+ @cache = InMemoryCache.new
19
+ @cache_fetcher = BulkCacheFetcher.new(@cache)
20
+ end
21
+
22
+ def test_returns_all_results_in_order
23
+ results = @cache_fetcher.fetch([5, 2, 3, 1, 4]) do |unfetched_objects|
24
+ unfetched_objects
25
+ end
26
+ assert_equal [5, 2, 3, 1, 4], results
27
+ end
28
+
29
+ def test_returns_without_calling_block_if_cached
30
+ counter = 0
31
+ @cache.write(1, 3)
32
+ @cache.write(2, 4)
33
+
34
+ results = @cache_fetcher.fetch([2, 1]) do |unfetched_objects|
35
+ counter += 1
36
+ unfetched_objects.map { |i| i += 2 }
37
+ end
38
+
39
+ assert_equal [4, 3], results
40
+ assert_equal 0, counter
41
+ end
42
+
43
+ def test_returns_some_from_cache_in_correct_order
44
+ counter = 0
45
+ @cache.write(1, 3)
46
+ @cache.write(2, 4)
47
+
48
+ results = @cache_fetcher.fetch([2, 3, 1, 4]) do |unfetched_objects|
49
+ counter += unfetched_objects.length
50
+ unfetched_objects.map { |i| i += 2 }
51
+ end
52
+
53
+ assert_equal [4, 5, 3, 6], results
54
+ assert_equal 2, counter
55
+ end
56
+
57
+ def test_uncached_records_stored_in_cache
58
+ @cache_fetcher.fetch([2, 3]) do |unfetched_objects|
59
+ unfetched_objects.map { |i| i += 2 }
60
+ end
61
+
62
+ assert_equal 4, @cache.read(2)
63
+ assert_equal 5, @cache.read(3)
64
+ end
65
+
66
+ def test_cache_key_with_associated_data
67
+ counter = 0
68
+ @cache.write(:one, 3)
69
+
70
+ identifiers = {three: 3, one: 1, two: 2}
71
+ results = @cache_fetcher.fetch(identifiers) do |unfetched_objects|
72
+ counter += unfetched_objects.length
73
+ unfetched_objects.values.map { |i| i += 2 }
74
+ end
75
+
76
+ assert_equal [5, 3, 4], results
77
+ assert_equal 2, counter
78
+ end
79
+
80
+ def test_succeeds_without_keys
81
+ results = @cache_fetcher.fetch([])
82
+ assert_equal [], results
83
+ end
84
+
85
+ def test_fails_with_too_many_keys
86
+ assert_raises ArgumentError do
87
+ @cache_fetcher.fetch([1, 2]) do |keys|
88
+ [1]
89
+ end
90
+ end
91
+ end
92
+
93
+ def test_fails_with_too_many_values
94
+ assert_raises ArgumentError do
95
+ @cache_fetcher.fetch([1, 2]) do |keys|
96
+ [1, 2, 3]
97
+ end
98
+ end
99
+ end
100
+
101
+ def test_can_take_cache_options
102
+ @cache_fetcher.fetch([1, 2], expires_in: 300) do |keys|
103
+ [1, 2]
104
+ end
105
+ assert_equal({expires_in: 300}, @cache.options[2])
106
+ end
107
+
108
+ def test_can_take_single_cache_key
109
+ @cache_fetcher.fetch(1) do |keys|
110
+ 2
111
+ end
112
+ assert_equal(2, @cache.read(1))
113
+ end
114
+
115
+ def test_complex_key_caches_under_correct_key
116
+ @cache_fetcher.fetch({:one => 1}) do |keys|
117
+ 2
118
+ end
119
+ assert_equal(2, @cache.read(:one))
120
+ end
121
+ end
@@ -0,0 +1,2 @@
1
+ require 'minitest/unit'
2
+ require 'minitest/autorun'
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bulk_cache_fetcher
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Justin Weiss
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-10-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ~>
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Fetches cache misses in bulk
56
+ email:
57
+ - jweiss@avvo.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - .gitignore
63
+ - Gemfile
64
+ - LICENSE.txt
65
+ - README.md
66
+ - Rakefile
67
+ - bulk_cache_fetcher.gemspec
68
+ - lib/bulk_cache_fetcher.rb
69
+ - test/bulk_cache_fetcher_test.rb
70
+ - test/test_helper.rb
71
+ homepage: https://github.com/justinweiss/bulk_cache_fetcher
72
+ licenses:
73
+ - MIT
74
+ metadata: {}
75
+ post_install_message:
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - '>='
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - '>='
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubyforge_project:
91
+ rubygems_version: 2.0.6
92
+ signing_key:
93
+ specification_version: 4
94
+ summary: Bulk Cache Fetcher allows you to query the cache for a list of objects, and
95
+ gives you the opportunity to fetch all of the cache misses at once, using whatever
96
+ `:include`s you want.
97
+ test_files:
98
+ - test/bulk_cache_fetcher_test.rb
99
+ - test/test_helper.rb