the-pantry 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
+ SHA1:
3
+ metadata.gz: 5d98a34b1bfccc46013bd9330cfbb48bb2c34a2e
4
+ data.tar.gz: e8c1a702702ba0cbefe61de8915b19c5c605e9a4
5
+ SHA512:
6
+ metadata.gz: f056f5e25806051ecad7f81fb01b436f2122a87116665300eccac0a1a6fc2a8c351721e585bddfc57180fe3cce9f628980f28b887efa95f57d5d2810f72ed487
7
+ data.tar.gz: 8c5745f26e77259b62b3b20467736918cd4b52ba79ad9687408f90eb7cff6a705d4681feccec4cd1b77c978540f968b903c57392296eb8df9764ee8912ae70c8
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ .idea/*
3
+ .ruby-gemset
4
+ /.yardoc
5
+ /_yardoc/
6
+ /coverage/
7
+ /doc/
8
+ /pkg/
9
+ /spec/reports/
10
+ /tmp/
11
+ /vendor/bundle/
12
+ sprig-pantry*.gem
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.3.1
data/.travis.yml ADDED
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.3.1
4
+ script: bundle exec rspec
5
+ services:
6
+ - redis-server
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in pantry.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,68 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ the-pantry (1.0.0)
5
+ activerecord (~> 4.2)
6
+ hiredis (~> 0.6)
7
+ json (~> 1.8)
8
+ redis (~> 3.2)
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ activemodel (4.2.7.1)
14
+ activesupport (= 4.2.7.1)
15
+ builder (~> 3.1)
16
+ activerecord (4.2.7.1)
17
+ activemodel (= 4.2.7.1)
18
+ activesupport (= 4.2.7.1)
19
+ arel (~> 6.0)
20
+ activesupport (4.2.7.1)
21
+ i18n (~> 0.7)
22
+ json (~> 1.7, >= 1.7.7)
23
+ minitest (~> 5.1)
24
+ thread_safe (~> 0.3, >= 0.3.4)
25
+ tzinfo (~> 1.1)
26
+ arel (6.0.4)
27
+ builder (3.2.3)
28
+ database_cleaner (1.4.1)
29
+ diff-lcs (1.2.5)
30
+ hiredis (0.6.1)
31
+ i18n (0.8.0)
32
+ json (1.8.6)
33
+ minitest (5.10.1)
34
+ rake (10.4.2)
35
+ redis (3.3.3)
36
+ rspec (3.3.0)
37
+ rspec-core (~> 3.3.0)
38
+ rspec-expectations (~> 3.3.0)
39
+ rspec-mocks (~> 3.3.0)
40
+ rspec-core (3.3.2)
41
+ rspec-support (~> 3.3.0)
42
+ rspec-expectations (3.3.1)
43
+ diff-lcs (>= 1.2.0, < 2.0)
44
+ rspec-support (~> 3.3.0)
45
+ rspec-mocks (3.3.2)
46
+ diff-lcs (>= 1.2.0, < 2.0)
47
+ rspec-support (~> 3.3.0)
48
+ rspec-support (3.3.0)
49
+ sqlite3 (1.3.10)
50
+ thread_safe (0.3.5)
51
+ timecop (0.7.4)
52
+ tzinfo (1.2.2)
53
+ thread_safe (~> 0.1)
54
+
55
+ PLATFORMS
56
+ ruby
57
+
58
+ DEPENDENCIES
59
+ bundler (~> 1.3)
60
+ database_cleaner (~> 1.3)
61
+ rake (~> 10.0)
62
+ rspec (~> 3.3)
63
+ sqlite3 (~> 1.3)
64
+ the-pantry!
65
+ timecop (~> 0.7)
66
+
67
+ BUNDLED WITH
68
+ 1.14.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 - 2017 Sprig
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,211 @@
1
+ # Pantry [![Build Status](https://travis-ci.org/eatsprig/the-pantry.svg?branch=master)](https://travis-ci.org/eatsprig/the-pantry)
2
+
3
+ Pantry provides ActiveRecord model caching in Redis via a mixin. **Before you go to the store, check the pantry!**
4
+
5
+ Pantry strives to be simple and intuitive. Its philosophy is that a cache should be dumb. You should use a cache to perform basic queries quickly; you should not use it to mimic a relational database. As a result, the query API that Pantry exposes is small and straightforward.
6
+
7
+ ## Features
8
+
9
+ + Retrieval of a single object from the cache by primary key.
10
+ + Retrieval of multiple objects from the cache by list of primary keys.
11
+ + Retrieval of all cached objects for a model in one fell swoop.
12
+ + Global and model-specific cache invalidation via configuration.
13
+ + Specification of cached object attributes via configuration.
14
+
15
+ ## Installation
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem 'the-pantry', require: 'pantry'
21
+ ```
22
+
23
+ And then execute:
24
+
25
+ $ bundle
26
+
27
+ Or install it yourself as:
28
+
29
+ $ gem install the-pantry
30
+
31
+ ## Usage
32
+
33
+ You can enable caching on your ActiveRecord model by mixing in `Pantry::Stocked`.
34
+
35
+ Basic example:
36
+ ```ruby
37
+ class User
38
+ include Pantry::Stocked
39
+ end
40
+ ```
41
+
42
+ Full example:
43
+ ```ruby
44
+ module UserMethods
45
+ def full_name
46
+ "#{first_name} #{last_name}".strip
47
+ end
48
+ end
49
+
50
+ module Cache
51
+ class User < Pantry::DryGood
52
+ include UserMethods
53
+ end
54
+ end
55
+
56
+ class User
57
+ include Pantry::Stocked
58
+ include UserMethods
59
+
60
+ stock_by :email
61
+
62
+ class << self
63
+ def local_key_version
64
+ 123
65
+ end
66
+
67
+ def dry_good_type
68
+ Cache::User
69
+ end
70
+ end
71
+
72
+ def pantry_attributes
73
+ super.merge(
74
+ "profile_photo_url" => profile_photo.url
75
+ )
76
+ end
77
+ end
78
+
79
+ user = User.find(1) #=> ActiveRecord::Base
80
+ user.full_name #=> Dude Guy
81
+ user.profile_photo.url #=> https://mybucket.s3.amazonaws.com/users/profile_photos/001/me.png
82
+
83
+ user = User.get(1) #=> Pantry::DryGood
84
+ user.full_name #=> Dude Guy
85
+ user.profile_photo_url #=> https://mybucket.s3.amazonaws.com/users/profile_photos/001/me.png
86
+ ```
87
+
88
+ The following methods will be added to the `User` class:
89
+
90
+ #### `User.get(id)`
91
+
92
+ Retrieves a single object, looking first to the cache and then to the database.
93
+
94
+ #### `User.get_by(attribute_name, attribute_value)`
95
+
96
+ Retrieves a single object by the value of one of its attributes, looking first to the cache and then to the database. In order to use this method, the model must declare `stock_by` with the desired attribute name.
97
+
98
+ #### `User.multi_get(ids)`
99
+
100
+ Retrieves multiple objects, looking first to the cache and then to the database.
101
+
102
+ #### `User.multi_get_by(attribute_name, attribute_values)`
103
+
104
+ Retrieves multiple objects by the given list of values for a single attribute. Looks first to the cache and then to the database.
105
+
106
+ #### `User.multi_get_all`
107
+
108
+ Retrieves all the objects that exist in the cache. Unlike other methods, this method does not fall back to the database unless the cache for the model in question is completely empty. The `restock?` configuration method (described below) must return `true` in order to use this method.
109
+
110
+ #### `User.invalidate(id)`
111
+
112
+ Removes a single entry from the cache.
113
+
114
+ #### `User.multi_invalidate(ids)`
115
+
116
+ Removes multiple entries from the cache.
117
+
118
+ #### `User.restock!`
119
+
120
+ Writes all model objects to the cache. This is mainly for use in conjunction with models that want to use the `multi_get_all` functionality. This is potentially very expensive, so it should be used with caution.
121
+
122
+ #### `User.tidy!`
123
+
124
+ Clears all expired entries from the index key used to serve `multi_get_all` requests. Never calling this method will not affect correctness (i.e. expired entries will never be returned from the index), but your index will grow indefinitely.
125
+
126
+ ## Model Configuration
127
+
128
+ You can customize the behaviour of the cache by overriding a few configuration options:
129
+
130
+ Example:
131
+ ```ruby
132
+ class User
133
+ include Pantry::Stocked
134
+
135
+ def self.local_key_prefix
136
+ "my_application_specific_prefix"
137
+ end
138
+
139
+ def self.restock?
140
+ true
141
+ end
142
+
143
+ def pantry_attributes
144
+ {
145
+ "id": id,
146
+ "nickname": nickname
147
+ }
148
+ end
149
+ end
150
+ ```
151
+
152
+ #### `User.local_key_prefix`
153
+
154
+ Returns the portion of the cache key that is specific to this model. Defaults to the lower-cased class name.
155
+
156
+ #### `User.local_key_version`
157
+
158
+ Returns the cache key version of cache objects for this model. Defaults to `1`.
159
+
160
+ #### `User.key_ttl_s`
161
+
162
+ Returns the TTL for cache keys for this model. Defaults to the `Pantry.global_key_ttl_s` value (which, by default, is 1 week).
163
+
164
+ #### `User.restock?`
165
+
166
+ Returns a boolean indicating whether or not the cache should be eagerly re-populated with a record after a change to that record was committed. This method must be configured to return `true` in order to use the `multi_get_all` method. Defaults to `false`.
167
+
168
+ #### `User.dry_good_type`
169
+
170
+ Allows for specifying a class to instantiate when an object is de-serialized from cache. The returned class should quack like `OpenStruct.new` and accept the attributes returned by `pantry_attributes`. This defaults to `Pantry::DryGood`.
171
+
172
+ #### `User#pantry_attributes`
173
+
174
+ Returns a hash of attributes to write to the cache.
175
+
176
+ #### `User.stock_by(attribute_name, unique: false)`
177
+
178
+ Adds a secondary cache index for the given attribute. This enables the use of `get_by` and `multi_get_by`. The `unique` flag specifies whether or not to create a unique index.
179
+
180
+ ## Global Configuration
181
+
182
+ Finally, there are global configuration options that you can set on the `Pantry.configuration` hash to manage configuration across all cached models:
183
+
184
+ ```ruby
185
+ # config/initializers/pantry.rb
186
+
187
+ Pantry.configuration.redis_uri = "some-redis-host.com:12345" # defaults to "localhost:6379"
188
+ Pantry.configuration.global_key_prefix = "myapp" # defaults to "pantry"
189
+ Pantry.configuration.global_key_version = 123 # defaults to 1
190
+ Pantry.configuration.global_key_ttl_s = 30.days # defaults to 1 week
191
+ Pantry.configuration.force_cache_misses = true # defaults to false
192
+ ```
193
+
194
+ ## Development
195
+
196
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
197
+
198
+ To install this gem onto your local machine, run `bundle exec rake install`.
199
+
200
+ To release a new version:
201
+ + Update the version number in `lib/pantry/version.rb`
202
+ + `git tag -a -m "Version <VERSION_NUMBER>" v<VERSION_NUMBER>`
203
+
204
+ ## Contributing
205
+
206
+ Bug reports and pull requests are welcome on GitHub.
207
+
208
+ ## License
209
+
210
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
211
+
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "gemfury/tasks"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "pantry"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,3 @@
1
+ module Pantry
2
+ class ConfigurationError < StandardError; end
3
+ end
@@ -0,0 +1,26 @@
1
+ module Pantry
2
+ class DryGood < OpenStruct
3
+ include ActiveModel::Serialization
4
+
5
+ # Implements required interface method for ActiveModel::Serialization.
6
+ def attributes
7
+ to_h.deep_stringify_keys
8
+ end
9
+
10
+ def ==(other)
11
+ super
12
+ end
13
+
14
+ def hash
15
+ super
16
+ end
17
+
18
+ # OpenStruct objects don't turn into hashes wrapped in a 'table' key
19
+ # http://goo.gl/vPjtgz
20
+ def as_json(options = nil)
21
+ @table.as_json(options)
22
+ end
23
+
24
+ alias_method :eql?, :==
25
+ end
26
+ end
@@ -0,0 +1,518 @@
1
+ module Pantry
2
+ module Stocked
3
+ extend ActiveSupport::Concern
4
+
5
+ @@includers = []
6
+
7
+ def self.includers
8
+ @@includers
9
+ end
10
+
11
+ included do
12
+ @@includers << self
13
+ after_commit -> (record) { self.class.invalidate(record) }
14
+ end
15
+
16
+ module ClassMethods
17
+
18
+ def local_key_prefix
19
+ to_s.downcase
20
+ end
21
+
22
+ def local_key_version
23
+ 1
24
+ end
25
+
26
+ def key_ttl_s
27
+ Pantry.configuration.global_key_ttl_s
28
+ end
29
+
30
+ def restock?
31
+ false
32
+ end
33
+
34
+ # Allows the client (the includer of this module) to declare a secondary
35
+ # index on the given attribute.
36
+ #
37
+ # @param attribute [Symbol, String] attribute to stock by, should be an
38
+ # attribute of the Class
39
+ # @param unique [Boolean, nil] Whether or not the declared index is
40
+ # unique.
41
+ # @param &block [Block] The block should behave like a block you'd use
42
+ # in a `scope`. It should expect to be chained on to an existing
43
+ # scoping chain, and should return a scope.
44
+ # Ex. stock_by(:team) { where.not(nickname: "Benji") }
45
+ def stock_by(attribute, unique: false, &block)
46
+ secondary_indices[attribute.to_sym] = {
47
+ unique: !!unique,
48
+ block: block
49
+ }
50
+ end
51
+
52
+ # Retrieves a single object, looking first to the cache and then to the
53
+ # database.
54
+ #
55
+ # @param id [Integer] Unique identifier of the desired object.
56
+ # @return [DryGood] Cached object. This object will have accessors for
57
+ # all the attributes returned by `pantry_attributes`.
58
+ def get(id)
59
+ multi_get([id])[id]
60
+ end
61
+
62
+ # Retrieves a single object by the value of an indexed attribute.
63
+ #
64
+ # @param attribute [String, Symbol] Name of indexed attribute.
65
+ # @param value [String] Stringified value of the indexed attribute for
66
+ # which to fetch the result.
67
+ # @return [DryGood] Cached object.
68
+ def get_by(attribute, value)
69
+ multi_get_by(attribute, [value])[value]
70
+ end
71
+
72
+ # Retrieves multiple objects, looking first to the cache and then to the
73
+ # database.
74
+ #
75
+ # @param ids [Array<Integer>] Unique identifiers of the desired objects.
76
+ # @return [Hash<Integer, DryGood>] Map from object identifier to cached
77
+ # object.
78
+ def multi_get(ids)
79
+ cached_records = multi_fetch(ids)
80
+
81
+ misses = cached_records.reduce([]) do |memo, (id, cached)|
82
+ memo << id unless cached
83
+ memo
84
+ end
85
+
86
+ db_records = misses.any? ? multi_store(where(id: misses).all.to_a) : {}
87
+
88
+ ids.reduce({}) do |memo, id|
89
+ memo[id] = cached_records[id] || db_records[id.to_i]
90
+ memo
91
+ end
92
+ end
93
+
94
+ # Retrieves a multiple objects by the values of an indexed attribute.
95
+ #
96
+ # @param attribute [String, Symbol] Name of indexed attribute.
97
+ # @param values [Array<String>] Stringified values of the indexed
98
+ # attribute for which to fetch the results.
99
+ # @return [Hash<String, DryGood>] Map from attribute value to cached
100
+ # object.
101
+ def multi_get_by(attribute, values)
102
+ cached_records = multi_fetch_by(attribute, values)
103
+
104
+ misses = []
105
+
106
+ empty_values = cached_records.reduce([]) do |memo, (val, cached)|
107
+ memo << val if cached.blank?
108
+ memo
109
+ end
110
+
111
+ if empty_values.any?
112
+ existences = Pantry.redis.pipelined do
113
+ empty_values.map do |val|
114
+ Pantry.redis.exists(secondary_index_cache_key(attribute, val))
115
+ end
116
+ end
117
+
118
+ empty_values.zip(existences).each do |(val, exists)|
119
+ unless exists
120
+ # Explicitly mark the cache miss (since otherwise, i.e. when the
121
+ # index key exists, an empty array is a valid cache response).
122
+ cached_records[val] = nil
123
+
124
+ misses << val
125
+ end
126
+ end
127
+ end
128
+
129
+ db_records = multi_store_by(attribute, misses)
130
+
131
+ values.reduce({}) do |memo, val|
132
+ memo[val] = cached_records[val] || db_records[val]
133
+ memo
134
+ end
135
+ end
136
+
137
+ # Retrieves all cached objects for this model class.
138
+ #
139
+ # This method should be used with caution: it may have negative
140
+ # performance consequences if used to retrieve large collections. It is
141
+ # intended for use on small collections that are important to business
142
+ # logic but that do not change often. Additionally, method will only work
143
+ # reliably if `restock?` is set to return `true`.
144
+ #
145
+ # @return [Hash<Integer, DryGood>] Map from object identifier to cached
146
+ # object.
147
+ def multi_get_all
148
+ unless restock?
149
+ fail ConfigurationError.new(
150
+ "restock?() must return true in order to use multi_get_all()")
151
+ end
152
+
153
+ if Pantry.configuration.force_cache_misses
154
+ result = all.reduce({}) do |memo, obj|
155
+ memo[obj.id] = DryGood.new(JSON.parse(obj.send(:pantry_json)))
156
+ memo
157
+ end
158
+ return result
159
+ end
160
+
161
+ keys = Pantry.redis.zrangebyscore(
162
+ all_index_cache_key,
163
+ Time.now.to_i,
164
+ "+inf")
165
+ if keys.empty?
166
+ restock!
167
+ else
168
+ multi_fetch(keys.map(&:to_i))
169
+ end
170
+ end
171
+
172
+ # Invalidates a single cache entry.
173
+ #
174
+ # @param obj_or_id [Object, Integer] Object (or primary key identifier for
175
+ # the object) whose cache entry we should delete.
176
+ def invalidate(obj_or_id)
177
+ multi_invalidate([obj_or_id])
178
+ end
179
+
180
+ # Invalidates a series of cache entries.
181
+ #
182
+ # @param objects_or_ids [Array<Object>, Array<Integer>] Objects (or
183
+ # primary key identifiers for objects) whose cache entries we should
184
+ # delete.
185
+ def multi_invalidate(objects_or_ids)
186
+ return if objects_or_ids.empty?
187
+
188
+ is_integer_list = objects_or_ids.first.is_a?(Integer)
189
+
190
+ objects = objects_or_ids
191
+ if is_integer_list && (secondary_indices.any? || restock?)
192
+ objects = where(id: objects_or_ids).all.to_a
193
+ end
194
+
195
+ Pantry.redis.del(
196
+ objects_or_ids.map { |o| cache_key(is_integer_list ? o : o.id) })
197
+
198
+ if secondary_indices.any?
199
+ invalidate_secondary_indices(objects)
200
+ end
201
+
202
+ if restock?
203
+ # TODO(lerebear): Make multi_store take optional list of cache keys.
204
+ # Store anything that we haven't already.
205
+ multi_store(
206
+ objects.find_all { |obj| !obj.destroyed? },
207
+ skip_deserialization: true)
208
+ end
209
+
210
+ nil
211
+ end
212
+
213
+ # We invalidate a key if either of the following is true:
214
+ # - The attribute is stocked conditionally (indicated by the presence
215
+ # of a block option)
216
+ # - The given objects' attributes that are indexed have changed. In
217
+ # this case we invalidate both the old and new values for the indexed
218
+ # attribute
219
+ #
220
+ # @param objects [Array<Object>]
221
+ def invalidate_secondary_indices(objects)
222
+ objects_by_dirty_index = secondary_indices
223
+ .reduce({}) do |memo, (attribute, options_hash)|
224
+ has_block = options_hash[:block]
225
+
226
+ if has_block
227
+ memo[attribute] = objects
228
+ else
229
+ dirty_objects = objects.find_all do |obj|
230
+ !obj.destroyed? && obj.previous_changes[attribute.to_s].present?
231
+ end
232
+
233
+ if dirty_objects.any?
234
+ memo[attribute] = dirty_objects
235
+ end
236
+ end
237
+
238
+ memo
239
+ end
240
+
241
+ Pantry.redis.pipelined do
242
+ objects_by_dirty_index.each do |attribute, dirty_objs|
243
+ # Invalidate attribute indices.
244
+ dirty_objs.each do |o|
245
+ (o.previous_changes[attribute.to_s] || [o.send(attribute)])
246
+ .each do |val|
247
+
248
+ Pantry.redis.del(secondary_index_cache_key(attribute, val))
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+
255
+ # Writes all objects for the model class to the cache.
256
+ #
257
+ # This is a utility method for ensuring that the cache is populated for
258
+ # classes that wish to use the `multi_get_all` functionality`. This method
259
+ # will only write to the cache if `restock?` is set to return
260
+ # `true`.
261
+ #
262
+ # @return [Hash<Integer, DryGood>, Nil] Map from object identifier to
263
+ # cached object, or nil if no objects were written to the cache.
264
+ def restock!
265
+ multi_store(all.to_a) if restock?
266
+ end
267
+
268
+ # Clears all expired entries from index keys.
269
+ #
270
+ # Entries in an index will never expire unless you call this method. That
271
+ # should be fine in most cases, and it never affects correctness (because
272
+ # only non-expired keys are returned by `multi_get_all`). However, this
273
+ # method can be used to address the concern of the values of index keys
274
+ # growing too big.
275
+ #
276
+ # @return [Integer, Nil] The number of elements that were expired, or nil
277
+ # if we aren't using indices.
278
+ def tidy!
279
+ # TODO(lerebear): This is no good because you have to call restock!
280
+ # alongside this to guarantee correctness. Better idea is probably to
281
+ # just have a clean! method that does both tidy! and restock! for the
282
+ # all_index_cache_key, and also removes all other attribute index cache
283
+ # keys at the same time (those will regenerate on the fly). Then make
284
+ # clear in the docs that you can use clean! on a schedule to help
285
+ # maintain your cache.
286
+ Pantry.redis.zremrangebyscore(all_index_cache_key, 0, Time.now.to_i - 1)
287
+ end
288
+
289
+ # Provides a hook for mixers to implement to provide a more specific
290
+ # class to instantiate when de-serializing from the cache.
291
+ #
292
+ # The returned class should quack like `OpenStruct#new`
293
+ #
294
+ # @return [Class]
295
+ def dry_good_type
296
+ DryGood
297
+ end
298
+
299
+ # Provides a hook for mixers to do pre-processing on the given
300
+ # objects to improve serialization performance.
301
+ #
302
+ # @param objects [Array<ActiveRecord::Base>] the objects fetched from the
303
+ # database on a cache miss
304
+ def before_pantry_serialize(objects)
305
+ end
306
+
307
+ private
308
+
309
+ def secondary_indices
310
+ @indices ||= {}
311
+ end
312
+
313
+ def fetch(id)
314
+ multi_fetch([id])[id]
315
+ end
316
+
317
+ def multi_fetch(ids)
318
+ return Hash[ids.zip([nil])] if Pantry.configuration.force_cache_misses
319
+
320
+ keys = ids.map { |id| cache_key(id) }
321
+ cached = keys.length > 0 ? Pantry.redis.mget(keys) : []
322
+ ids.zip(cached).reduce({}) do |memo, (id, retrieved)|
323
+ memo[id] = retrieved && restore_deserialized(JSON.parse(retrieved))
324
+ memo
325
+ end
326
+ end
327
+
328
+ def multi_fetch_by(attribute, values)
329
+ is_unique_index = secondary_indices[attribute.to_sym].try(:[], :unique)
330
+
331
+ if Pantry.configuration.force_cache_misses
332
+ return values.reduce({}) do |memo, val|
333
+ memo[val] = is_unique_index ? nil : []
334
+ memo
335
+ end
336
+ end
337
+
338
+ # TODO(lerebear): Create a pipelining helper to avoid pipeline cost for
339
+ # lists of 1.
340
+ id_lists = Pantry.redis.pipelined do
341
+ values.map do |val|
342
+ Pantry
343
+ .redis
344
+ .smembers(secondary_index_cache_key(attribute, val))
345
+ end
346
+ end
347
+
348
+ id_lists = id_lists.map { |ids| ids.map(&:to_i) }
349
+ cached = multi_get(id_lists.flatten)
350
+
351
+ values.zip(id_lists).reduce({}) do |memo, (val, ids)|
352
+ memo[val] = \
353
+ if is_unique_index
354
+ ids.first && cached[ids.first]
355
+ else
356
+ ids.reduce([]) do |acc, id|
357
+ if (retrieved = cached[id])
358
+ acc << retrieved
359
+ end
360
+ acc
361
+ end
362
+ end
363
+ memo
364
+ end
365
+ end
366
+
367
+ def restore_deserialized(attrs)
368
+ cast_attributes(dry_good_type.send(:new, attrs))
369
+ end
370
+
371
+ def cast_attributes(dry_good)
372
+ columns_hash.find_all { |_, hash| hash.type.to_s == "datetime" }
373
+ .map(&:first)
374
+ .each do |attr|
375
+ if (value = dry_good.send(attr)) && value.is_a?(String)
376
+ dry_good.send("#{attr}=", Time.parse(value))
377
+ end
378
+ end
379
+ dry_good
380
+ end
381
+
382
+ def store(id, skip_deserialization: false)
383
+ cached = multi_store(
384
+ where(id: id).all.to_a,
385
+ skip_deserialization: skip_deserialization)
386
+ cached[id] unless skip_deserialization
387
+ end
388
+
389
+ def multi_store(objects, skip_deserialization: false)
390
+ return {} if objects.empty?
391
+
392
+ before_pantry_serialize(objects)
393
+ object_ids = []
394
+ to_cache = {}
395
+ objects.each do |obj|
396
+ object_ids << obj.id
397
+ to_cache[cache_key(obj.id)] = obj.send(:pantry_json)
398
+ end
399
+
400
+ Pantry.redis.pipelined do
401
+ Pantry.redis.mset(*to_cache.flatten)
402
+ object_ids.each do |id|
403
+ Pantry.redis.expire(cache_key(id), key_ttl_s)
404
+ end
405
+
406
+ # Add to "all" index key if necessary.
407
+ now = Time.now.to_i
408
+ if restock?
409
+ Pantry.redis.zadd(
410
+ all_index_cache_key,
411
+ object_ids.map { |id| [now + key_ttl_s, id] })
412
+ end
413
+ end
414
+
415
+ unless skip_deserialization
416
+ # HACK(lerebear): Incurring the JSON parsing cost here is really bad.
417
+ # Ideally we would just return `pantry_attributes` of every object. The
418
+ # problem is that the hash returned by `pantry_attributes` still
419
+ # contains unserialized types (e.g. Date, which gets serialized to a
420
+ # string when written to the cache and so emerges from the cache as a
421
+ # string). That creates problems in tests and, conceptually, this should
422
+ # return the same sort of object as `multi_fetch`, so the workaround is
423
+ # to just parse the JSON.
424
+ to_cache.values.reduce({}) do |memo, obj|
425
+ restored = restore_deserialized(JSON.parse(obj))
426
+ memo[restored.id] = restored
427
+ memo
428
+ end
429
+ end
430
+ end
431
+
432
+ def multi_store_by(attribute, values, skip_deserialization: false)
433
+ return {} if values.empty?
434
+
435
+ is_unique_index = secondary_indices[attribute.to_sym].try(:[], :unique)
436
+ block = secondary_indices[attribute.to_sym].try(:[], :block)
437
+
438
+ scope = where(attribute.to_sym => values)
439
+
440
+ if block
441
+ scope = scope.scoping { block.call }
442
+ end
443
+
444
+ objects_by_attribute_value ||= \
445
+ scope
446
+ .all
447
+ .group_by { |obj| obj.send(attribute) }
448
+
449
+ Pantry.redis.pipelined do
450
+ # Populate secondary indices.
451
+ objects_by_attribute_value.each do |value, objs|
452
+ Pantry.redis.sadd(
453
+ secondary_index_cache_key(attribute, value),
454
+ objs.map(&:id))
455
+ end
456
+ end
457
+ # Also populate primary index (since we have the objects handy).
458
+ multi_store(
459
+ objects_by_attribute_value.values.flatten.sort_by(&:id),
460
+ skip_deserialization: true)
461
+
462
+ unless skip_deserialization
463
+ # HACK(lerebear): Same hack as in `multi_store` method above.
464
+ values.reduce({}) do |memo, val|
465
+ objs = objects_by_attribute_value[val] || []
466
+ memo[val] = \
467
+ if is_unique_index
468
+ objs.first && restore_deserialized(
469
+ JSON.parse(objs.first.send(:pantry_json)))
470
+ else
471
+ objs.map do |o|
472
+ restore_deserialized(JSON.parse(o.send(:pantry_json)))
473
+ end
474
+ end
475
+ memo
476
+ end
477
+ end
478
+ end
479
+
480
+ def cache_key(id)
481
+ [
482
+ Pantry.configuration.global_key_prefix,
483
+ "v#{Pantry.configuration.global_key_version}",
484
+ local_key_prefix,
485
+ "v#{local_key_version}",
486
+ "##{id}"
487
+ ].join(":")
488
+ end
489
+
490
+ def secondary_index_cache_key(attribute, value)
491
+ [
492
+ Pantry.configuration.global_key_prefix,
493
+ "v#{Pantry.configuration.global_key_version}",
494
+ local_key_prefix,
495
+ "v#{local_key_version}",
496
+ "index",
497
+ attribute,
498
+ value.nil? ? "__pantry_nil__" : value
499
+ ].join(":")
500
+ end
501
+
502
+ def all_index_cache_key
503
+ secondary_index_cache_key(:id, "all")
504
+ end
505
+ end
506
+
507
+ # @return [Hash] Attributes to cache.
508
+ def pantry_attributes
509
+ attributes
510
+ end
511
+
512
+ private
513
+
514
+ def pantry_json
515
+ pantry_attributes.to_json
516
+ end
517
+ end
518
+ end
@@ -0,0 +1,3 @@
1
+ module Pantry
2
+ VERSION = "1.0.0"
3
+ end
data/lib/pantry.rb ADDED
@@ -0,0 +1,46 @@
1
+ require "active_record"
2
+ require "hiredis"
3
+ require "json"
4
+ require "ostruct"
5
+ require "redis"
6
+
7
+ require "pantry/version"
8
+ require "pantry/configuration_error"
9
+ require "pantry/dry_good"
10
+ require "pantry/stocked"
11
+
12
+ module Pantry
13
+ class << self
14
+ # Returns the (mutable) configuration object for this module.
15
+ #
16
+ # The following keys are supported:
17
+ # :redis_uri - The Redis instance to use as the cache. Defaults to nil,
18
+ # which in turn allows the Redis library to connect to the default host.
19
+ # :global_key_prefix - The portion of the cache key prefix that should be
20
+ # used for all cache keys. Defaults to `pantry`.
21
+ # :global_key_version - The key version to use for all cache keys.
22
+ # Defaults to `1`.
23
+ # :global_key_ttl_s - The default TTL (in seconds) to apply to all keys.
24
+ # Defaults to 1 week.
25
+ # :force_cache_misses - Whether or not to ignore the cache completely on
26
+ # read. Defaults to false.
27
+ # @return [OpenStruct] Configuration object.
28
+ def configuration
29
+ @configuration ||= OpenStruct.new(
30
+ redis_uri: nil,
31
+ global_key_prefix: "pantry",
32
+ global_key_version: 1,
33
+ global_key_ttl_s: 60 * 60 * 24 * 7 * 2,
34
+ force_cache_misses: false)
35
+ end
36
+
37
+ # Returns the redis instance that this module is using as a cache.
38
+ #
39
+ # @return [Redis] Redis instance.
40
+ def redis
41
+ @redis ||= Redis.new(
42
+ url: Pantry.configuration.redis_uri,
43
+ driver: :hiredis)
44
+ end
45
+ end
46
+ end
data/pantry.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ $LOAD_PATH.unshift(File.expand_path("../lib", __FILE__))
2
+
3
+ require "pantry/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "the-pantry"
7
+ spec.version = Pantry::VERSION
8
+ spec.authors = ["sprig"]
9
+
10
+ spec.summary = "Before you go to the store, check the pantry!"
11
+ spec.description = "Simple ActiveRecord model caching using Redis."
12
+ spec.license = "MIT"
13
+
14
+ spec.files = `git ls-files -z`
15
+ .split("\x0")
16
+ .reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_runtime_dependency "activerecord", "~> 4.2"
22
+ spec.add_runtime_dependency "hiredis", "~> 0.6"
23
+ spec.add_runtime_dependency "json", "~> 1.8"
24
+ spec.add_runtime_dependency "redis", "~> 3.2"
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.3"
27
+ spec.add_development_dependency "database_cleaner", "~> 1.3"
28
+ spec.add_development_dependency "rake", "~> 10.0"
29
+ spec.add_development_dependency "rspec", "~> 3.3"
30
+ spec.add_development_dependency "sqlite3", "~> 1.3"
31
+ spec.add_development_dependency "timecop", "~> 0.7"
32
+ end
metadata ADDED
@@ -0,0 +1,200 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: the-pantry
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - sprig
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-03-30 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: '4.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: hiredis
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: json
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.8'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.8'
55
+ - !ruby/object:Gem::Dependency
56
+ name: redis
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: bundler
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: database_cleaner
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.3'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.3'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '10.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '10.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.3'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.3'
125
+ - !ruby/object:Gem::Dependency
126
+ name: sqlite3
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.3'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.3'
139
+ - !ruby/object:Gem::Dependency
140
+ name: timecop
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '0.7'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '0.7'
153
+ description: Simple ActiveRecord model caching using Redis.
154
+ email:
155
+ executables: []
156
+ extensions: []
157
+ extra_rdoc_files: []
158
+ files:
159
+ - ".gitignore"
160
+ - ".rspec"
161
+ - ".ruby-version"
162
+ - ".travis.yml"
163
+ - Gemfile
164
+ - Gemfile.lock
165
+ - LICENSE.txt
166
+ - README.md
167
+ - Rakefile
168
+ - bin/console
169
+ - bin/setup
170
+ - lib/pantry.rb
171
+ - lib/pantry/configuration_error.rb
172
+ - lib/pantry/dry_good.rb
173
+ - lib/pantry/stocked.rb
174
+ - lib/pantry/version.rb
175
+ - pantry.gemspec
176
+ homepage:
177
+ licenses:
178
+ - MIT
179
+ metadata: {}
180
+ post_install_message:
181
+ rdoc_options: []
182
+ require_paths:
183
+ - lib
184
+ required_ruby_version: !ruby/object:Gem::Requirement
185
+ requirements:
186
+ - - ">="
187
+ - !ruby/object:Gem::Version
188
+ version: '0'
189
+ required_rubygems_version: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - ">="
192
+ - !ruby/object:Gem::Version
193
+ version: '0'
194
+ requirements: []
195
+ rubyforge_project:
196
+ rubygems_version: 2.6.9
197
+ signing_key:
198
+ specification_version: 4
199
+ summary: Before you go to the store, check the pantry!
200
+ test_files: []