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 +7 -0
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +72 -0
- data/Rakefile +16 -0
- data/bulk_cache_fetcher.gemspec +26 -0
- data/lib/bulk_cache_fetcher.rb +99 -0
- data/test/bulk_cache_fetcher_test.rb +121 -0
- data/test/test_helper.rb +2 -0
- metadata +99 -0
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
data/Gemfile
ADDED
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
|
data/test/test_helper.rb
ADDED
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
|