bulk_cache_fetcher 0.0.1

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
+ 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