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