readthis 1.1.0 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c15a08e16c0c3499ba92207c32a0fb3cd07d6bea
4
- data.tar.gz: 77b6b856ee036051b59a83c7a2013af49ef4c898
3
+ metadata.gz: d99b745b536b4761198212736eba995173cf7dd0
4
+ data.tar.gz: fb66dd2f67cc90114ad5037ea10b684722d6d468
5
5
  SHA512:
6
- metadata.gz: e1c2bd1bcadb0b1d4636de110c1202adf9f4c197bc189ef5282d86f4765612d5383ec9792d435b0629012732151e5a7468a965cc7d99a8b14e9e95100d6312af
7
- data.tar.gz: 8ab80c80422fa2ad11d70e03ad6697c6a5d5d78fc76460b27cf1a332b023b9b0419c40020d291cc8db01638844dfe33a693c12a59e1d28474b9f858dc06998cf
6
+ metadata.gz: 992a39d5b3c6fde1796dbd8ac8dc8c03aa44cd79152a15e6b1ca553327d4dec95f754ddb68a2275aa636d75d13658e43b712ebd95ae4b49ae21f6a164ad66b87
7
+ data.tar.gz: e5521f55739341901c7bbe6d9743127686643ca44cb82bf338f046a81fd16a522f364ee757e5f548f3ed9c647b495904c0faa1144470e47f4610f510cebc23e1
data/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
  [![Build Status](https://travis-ci.org/sorentwo/readthis.svg?branch=master)](https://travis-ci.org/sorentwo/readthis)
3
3
  [![Code Climate](https://codeclimate.com/github/sorentwo/readthis/badges/gpa.svg)](https://codeclimate.com/github/sorentwo/readthis)
4
4
  [![Coverage Status](https://coveralls.io/repos/sorentwo/readthis/badge.svg?branch=master&service=github)](https://coveralls.io/github/sorentwo/readthis?branch=master)
5
+ [![Inline Docs](http://inch-ci.org/github/sorentwo/readthis.svg?branch=master)](http://inch-ci.org/github/sorentwo/readthis)
5
6
 
6
7
  # Readthis
7
8
 
8
- Readthis is a drop in replacement for any ActiveSupport compliant cache. It
9
- emphasizes performance and simplicity and takes some cues from Dalli the popular
10
- Memcache client.
9
+ Readthis is a Redis backed cache client for Ruby. It is a drop in replacement
10
+ for any `ActiveSupport` compliant cache and can also be used for [session
11
+ storage](#session-storage). Above all Readthis emphasizes performance,
12
+ simplicity, and explicitness.
11
13
 
12
14
  For new projects there isn't any reason to stick with Memcached. Redis is as
13
15
  fast, if not faster in many scenarios, and is far more likely to be used
@@ -65,24 +67,40 @@ instances have numerous benefits like: more predictable performance, avoiding
65
67
  expires in favor of LRU, and tuning the persistence mechanism. See [Optimizing
66
68
  Redis Usage for Caching][optimizing-usage] for more details.
67
69
 
68
- [optimizing-usage]: http://sorentwo.com/2015/07/27/optimizing-redis-usage-for-caching.html
69
-
70
- At the very least you'll want to use a specific database for caching. In the
70
+ At the very least, you'll want to use a specific database for caching. In the
71
71
  event the database needs to be purged you can do so with a single `clear`
72
72
  command, rather than finding all keys in a namespace and deleting them.
73
73
  Appending a number between 0 and 15 will specify the redis database, which
74
- defaults to 0. For example, using database 2:
74
+ defaults to `0`. For example, using database `2`:
75
75
 
76
76
  ```bash
77
77
  REDIS_URL=redis://localhost:6379/2
78
78
  ```
79
79
 
80
+ [optimizing-usage]: http://sorentwo.com/2015/07/27/optimizing-redis-usage-for-caching.html
81
+
80
82
  ### Expiration
81
83
 
82
84
  Be sure to use an integer value when setting expiration time. The default
83
85
  representation of `ActiveSupport::Duration` values won't work when setting
84
86
  expiration time, which will cause all keys to have `-1` as the TTL. Expiration
85
- values are always cast as an integer on write.
87
+ values are always cast as an integer on write. For example:
88
+
89
+ ```ruby
90
+ Readthis::Cache.new(expires_in: 1.week) # don't do this
91
+ Readthis::Cache.new(expires_in: 1.week.to_i) # do this
92
+ ```
93
+
94
+ By using the `refresh` option the TTL for keys can be refreshed automatically
95
+ every time the key is read. This is helpful for ensuring commonly hit keys are
96
+ kept cached, effectively making the cache a hybrid LRU.
97
+
98
+ ```ruby
99
+ Readthis::Cache.new(refresh: true)
100
+ ```
101
+
102
+ Be aware that `refresh` adds a slight overhead to all read operations, as they
103
+ are now all write operations as well.
86
104
 
87
105
  ### Compression
88
106
 
@@ -132,23 +150,68 @@ Readthis.serializers.freeze!
132
150
  Readthis::Cache.new(marshal: Oj)
133
151
  ```
134
152
 
135
- Be aware that the order in which you add serializers matters. Serializers are
153
+ Be aware that the *order in which you add serializers matters*. Serializers are
136
154
  sticky and a flag is stored with each cached value. If you subsequently go to
137
155
  deserialize values and haven't configured the same serializers in the same order
138
156
  your application will raise errors.
139
157
 
158
+ ## Fault Tolerance
159
+
160
+ In some situations it is desirable to keep serving requests from disk or the
161
+ database if Redis crashes. This can be achieved with connection fault tolerance
162
+ by enabling it at the top level:
163
+
164
+ ```ruby
165
+ Readthis.fault_tolerant = true
166
+ ```
167
+
168
+ The default value is `false`, because while it may work for `fetch` operations,
169
+ it isn't compatible with other state-based commands like `increment`.
170
+
171
+ ## Running Arbitrary Redis Commands
172
+
173
+ Readthis provides access to the underlying Redis connection pool, allowing you
174
+ to run arbitrary commands directly through the cache instance. For example, if
175
+ you wanted to expire a key manually using an instance of `Rails.cache`:
176
+
177
+ ```ruby
178
+ Rails.cache.pool.with { |client| client.expire('foo-key', 60) }
179
+ ```
180
+
140
181
  ## Differences From ActiveSupport::Cache
141
182
 
142
183
  Readthis supports all of standard cache methods except for the following:
143
184
 
144
185
  * `cleanup` - Redis does this with TTL or LRU already.
145
- * `delete_matched` - You really don't want to perform key matching operations in
146
- Redis. They are linear time and only support basic globbing.
186
+ * `mute` and `silence!` - You must subscribe to the events `/cache*.active_support/`
187
+ with `ActiveSupport::Notifications` to [log cache calls manually][notifications].
188
+
189
+ [notifications]: https://github.com/sorentwo/readthis/issues/22#issuecomment-142595938
147
190
 
148
191
  Like other `ActiveSupport::Cache` implementations it is possible to cache `nil`
149
192
  as a value. However, the fetch methods treat `nil` values as a cache miss and
150
193
  re-generate/re-cache the value. Caching `nil` isn't recommended.
151
194
 
195
+ ## Session Storage
196
+
197
+ By using [ActionDispatch::Session::CacheStore][cache-store] it's possible to
198
+ reuse `:readthis_store` or specify a new Readthis cache store for storing
199
+ sessions.
200
+
201
+ ```ruby
202
+ Rails.application.config.session_store :cache_store
203
+ ```
204
+
205
+ To specify a separate Readthis instance you can use the `:cache` option:
206
+
207
+ ```ruby
208
+ Rails.application.config.session_store :cache_store,
209
+ cache: Readthis::Cache.new,
210
+ expire_after: 2.weeks.to_i
211
+ ```
212
+
213
+ [cache-store]: http://api.rubyonrails.org/classes/ActionDispatch/Session/CacheStore.html
214
+
152
215
  ## Contributing
153
216
 
154
217
  1. Fork it
@@ -1,6 +1,9 @@
1
1
  require 'readthis'
2
2
 
3
3
  module ActiveSupport
4
+ # Provided for compatibility with ActiveSupport's cache lookup behavior. When
5
+ # the ActiveSupport `cache_store` is set to `:readthis_store` it will resolve
6
+ # to `Readthis::Cache`.
4
7
  module Cache
5
8
  ReadthisStore ||= Readthis::Cache # rubocop:disable Style/ConstantName
6
9
  end
data/lib/readthis.rb CHANGED
@@ -4,16 +4,39 @@ require 'readthis/serializers'
4
4
  require 'readthis/version'
5
5
 
6
6
  module Readthis
7
- extend self
8
-
9
7
  # The current, global, instance of serializers that is used by all cache
10
8
  # instances.
11
9
  #
12
- # @returns [Readthis::Serializers] An cached Serializers instance
10
+ # @return [Readthis::Serializers] An cached Serializers instance
13
11
  #
14
12
  # @see readthis/serializers
15
13
  #
16
14
  def serializers
17
15
  @serializers ||= Readthis::Serializers.new
18
16
  end
17
+
18
+ # Indicates whether connection error tolerance is enabled. With tolerance
19
+ # enabled every operation will return a `nil` value.
20
+ #
21
+ # @return [Boolean] True for enabled, false for disabled
22
+ #
23
+ def fault_tolerant?
24
+ @fault_tolerant
25
+ end
26
+
27
+ # Toggle fault tolerance for connection errors.
28
+ #
29
+ # @param [Boolean] value The new value for fault tolerance
30
+ #
31
+ def fault_tolerant=(value)
32
+ @fault_tolerant = value
33
+ end
34
+
35
+ # @private
36
+ def reset!
37
+ @fault_tolerant = nil
38
+ @serializers = nil
39
+ end
40
+
41
+ module_function :serializers, :fault_tolerant?, :fault_tolerant=, :reset!
19
42
  end
@@ -1,12 +1,13 @@
1
1
  require 'readthis/entity'
2
2
  require 'readthis/expanders'
3
3
  require 'readthis/passthrough'
4
+ require 'readthis/scripts'
4
5
  require 'redis'
5
6
  require 'connection_pool'
6
7
 
7
8
  module Readthis
8
9
  class Cache
9
- attr_reader :entity, :notifications, :options, :pool
10
+ attr_reader :entity, :notifications, :options, :pool, :scripts
10
11
 
11
12
  # Provide a class level lookup of the proper notifications module.
12
13
  # Instrumention is expected to occur within applications that have
@@ -18,17 +19,19 @@ module Readthis
18
19
 
19
20
  # Creates a new Readthis::Cache object with the given options.
20
21
  #
21
- # @option [Hash] :redis Options that will be passed to the underlying redis connection
22
- # @option [Boolean] :compress (false) Enable or disable automatic compression
23
- # @option [Number] :compression_threshold (8k) The size a string must be for compression
24
- # @option [Number] :expires_in The number of seconds until an entry expires
25
- # @option [Module] :marshal (Marshal) Any module that responds to `dump` and `load`
26
- # @option [String] :namespace Prefix used to namespace entries
27
- # @option [Number] :pool_size (5) The number of threads in the pool
28
- # @option [Number] :pool_timeout (5) How long before a thread times out
22
+ # @option options [Hash] :redis Options that will be passed to the redis connection
23
+ # @option options [Boolean] :compress (false) Enable or disable automatic compression
24
+ # @option options [Number] :compression_threshold (8k) Minimum string size for compression
25
+ # @option options [Number] :expires_in The number of seconds until an entry expires
26
+ # @option options [Boolean] :refresh (false) Automatically refresh key expiration
27
+ # @option options [Module] :marshal (Marshal) Module that responds to `dump` and `load`
28
+ # @option options [String] :namespace Prefix used to namespace entries
29
+ # @option options [Number] :pool_size (5) The number of threads in the pool
30
+ # @option options [Number] :pool_timeout (5) How long before a thread times out
29
31
  #
30
32
  # @example Create a new cache instance
31
- # Readthis::Cache.new(namespace: 'cache', redis: { url: 'redis://localhost:6379/0' })
33
+ # Readthis::Cache.new(namespace: 'cache',
34
+ # redis: { url: 'redis://localhost:6379/0' })
32
35
  #
33
36
  # @example Create a compressed cache instance
34
37
  # Readthis::Cache.new(compress: true, compression_threshold: 2048)
@@ -45,14 +48,16 @@ module Readthis
45
48
  @pool = ConnectionPool.new(pool_options(options)) do
46
49
  Redis.new(options.fetch(:redis, {}))
47
50
  end
51
+
52
+ @scripts = Readthis::Scripts.new
48
53
  end
49
54
 
50
55
  # Fetches data from the cache, using the given key. If there is data in
51
56
  # the cache with the given key, then that data is returned. Otherwise, nil
52
57
  # is returned.
53
58
  #
54
- # @param [String] Key for lookup
55
- # @param [Hash] Optional overrides
59
+ # @param [String] key Key for lookup
60
+ # @param [Hash] options Optional overrides
56
61
  #
57
62
  # @example
58
63
  #
@@ -60,18 +65,22 @@ module Readthis
60
65
  # cache.read('matched') # => 'some value'
61
66
  #
62
67
  def read(key, options = {})
68
+ options = merged_options(options)
69
+
63
70
  invoke(:read, key) do |store|
64
- value = store.get(namespaced_key(key, merged_options(options)))
71
+ key = namespaced_key(key, options)
72
+
73
+ refresh_entity(key, store, options)
65
74
 
66
- entity.load(value)
75
+ entity.load(store.get(key))
67
76
  end
68
77
  end
69
78
 
70
79
  # Writes data to the cache using the given key. Will overwrite whatever
71
80
  # value is already stored at that key.
72
81
  #
73
- # @param [String] Key for lookup
74
- # @param [Hash] Optional overrides
82
+ # @param [String] key Key for lookup
83
+ # @param [Hash] options Optional overrides
75
84
  #
76
85
  # @example
77
86
  #
@@ -90,13 +99,14 @@ module Readthis
90
99
  # Delete the value stored at the specified key. Returns `true` if
91
100
  # anything was deleted, `false` otherwise.
92
101
  #
93
- # @params [String] The key for lookup
94
- # @params [Hash] Optional overrides
102
+ # @param [String] key The key for lookup
103
+ # @param [Hash] options Optional overrides
95
104
  #
96
105
  # @example
97
106
  #
98
107
  # cache.delete('existing-key') # => true
99
108
  # cache.delete('random-key') # => false
109
+ #
100
110
  def delete(key, options = {})
101
111
  namespaced = namespaced_key(key, merged_options(options))
102
112
 
@@ -105,6 +115,49 @@ module Readthis
105
115
  end
106
116
  end
107
117
 
118
+ # Delete all values that match a given pattern. The pattern must be defined
119
+ # using Redis compliant globs. The following examples are borrowed from the
120
+ # `KEYS` documentation:
121
+ #
122
+ # * `h?llo` matches hello, hallo and hxllo
123
+ # * `h*llo` matches hllo and heeeello
124
+ # * `h[ae]llo` matches hello and hallo, but not hillo
125
+ # * `h[^e]llo` matches hallo, hbllo, ... but not hello
126
+ # * `h[a-b]llo` matches hallo and hbllo
127
+ #
128
+ # Note that `delete_matched` does *not* use the `KEYS` command, making it
129
+ # safe for use in production.
130
+ #
131
+ # @param [String] pattern The glob pattern for matching keys
132
+ # @option [String] :namespace Prepend a namespace to the pattern
133
+ # @option [Number] :count Configure the number of keys deleted at once
134
+ #
135
+ # @example Delete all 'cat' keys
136
+ #
137
+ # cache.delete_matched('*cats') #=> 47
138
+ # cache.delete_matched('*dogs') #=> 0
139
+ #
140
+ def delete_matched(pattern, options = {})
141
+ namespaced = namespaced_key(pattern, merged_options(options))
142
+
143
+ invoke(:delete, pattern) do |store|
144
+ cursor = nil
145
+ count = options.fetch(:count, 1000)
146
+ deleted = 0
147
+
148
+ until cursor == '0'.freeze
149
+ cursor, matched = store.scan(cursor || 0, match: namespaced, count: count)
150
+
151
+ if matched.any?
152
+ store.del(*matched)
153
+ deleted += matched.length
154
+ end
155
+ end
156
+
157
+ deleted
158
+ end
159
+ end
160
+
108
161
  # Fetches data from the cache, using the given key. If there is data in the
109
162
  # cache with the given key, then that data is returned.
110
163
  #
@@ -114,10 +167,11 @@ module Readthis
114
167
  # the block will be written to the cache under the given cache key, and
115
168
  # that return value will be returned.
116
169
  #
117
- # @param [String] Key for lookup
118
- # @param [Block] Optional block for generating the value when missing
170
+ # @param [String] key Key for lookup
119
171
  # @param options [Hash] Optional overrides
120
172
  # @option options [Boolean] :force Force a cache miss
173
+ # @yield [String] Gives a missing key to the block, which is used to
174
+ # generate the missing value
121
175
  #
122
176
  # @example Typical
123
177
  #
@@ -130,7 +184,8 @@ module Readthis
130
184
  # cache.fetch('city') do
131
185
  # 'Duckburgh'
132
186
  # end
133
- # cache.fetch('city') # => "Duckburgh"
187
+ #
188
+ # cache.fetch('city') # => "Duckburgh"
134
189
  #
135
190
  # @example Cache Miss
136
191
  #
@@ -154,9 +209,9 @@ module Readthis
154
209
  # If the key doesn't exist it will be initialized at 0. If the key exists
155
210
  # but it isn't a Fixnum it will be initialized at 0.
156
211
  #
157
- # @param [String] Key for lookup
158
- # @param [Fixnum] Value to increment by
159
- # @param [Hash] Optional overrides
212
+ # @param [String] key Key for lookup
213
+ # @param [Fixnum] amount Value to increment by
214
+ # @param [Hash] options Optional overrides
160
215
  #
161
216
  # @example
162
217
  #
@@ -175,9 +230,9 @@ module Readthis
175
230
  # If the key doesn't exist it will be initialized at 0. If the key exists
176
231
  # but it isn't a Fixnum it will be initialized at 0.
177
232
  #
178
- # @param [String] Key for lookup
179
- # @param [Fixnum] Value to decrement by
180
- # @param [Hash] Optional overrides
233
+ # @param [String] key Key for lookup
234
+ # @param [Fixnum] amount Value to decrement by
235
+ # @param [Hash] options Optional overrides
181
236
  #
182
237
  # @example
183
238
  #
@@ -212,7 +267,9 @@ module Readthis
212
267
  return {} if keys.empty?
213
268
 
214
269
  invoke(:read_multi, keys) do |store|
215
- values = store.mget(mapping).map { |value| entity.load(value) }
270
+ values = store.mget(*mapping).map { |value| entity.load(value) }
271
+
272
+ refresh_entity(mapping, store, options)
216
273
 
217
274
  keys.zip(values).to_h
218
275
  end
@@ -224,8 +281,8 @@ module Readthis
224
281
  #
225
282
  # This is a non-standard, but useful, cache method.
226
283
  #
227
- # @param [Hash] Key value hash to write
228
- # @param [Hash] Optional overrides
284
+ # @param [Hash] hash Key value hash to write
285
+ # @param [Hash] options Optional overrides
229
286
  #
230
287
  # @example
231
288
  #
@@ -282,8 +339,8 @@ module Readthis
282
339
 
283
340
  # Returns `true` if the cache contains an entry for the given key.
284
341
  #
285
- # @param [String] Key for lookup
286
- # @param [Hash] Optional overrides
342
+ # @param [String] key Key for lookup
343
+ # @param [Hash] options Optional overrides
287
344
  #
288
345
  # @example
289
346
  #
@@ -299,23 +356,32 @@ module Readthis
299
356
  # Clear the entire cache. This flushes the current database, no
300
357
  # globbing is applied.
301
358
  #
302
- # @param [Hash] Options, only present for compatibility.
359
+ # @param [Hash] _options Options, only present for compatibility
303
360
  #
304
361
  # @example
305
362
  #
306
363
  # cache.clear #=> 'OK'
364
+ #
307
365
  def clear(_options = nil)
308
366
  invoke(:clear, '*', &:flushdb)
309
367
  end
310
368
 
311
369
  protected
312
370
 
371
+ def refresh_entity(keys, store, options)
372
+ return unless options[:refresh] && options[:expires_in]
373
+
374
+ expiration = coerce_expiration(options[:expires_in])
375
+
376
+ scripts.run('mexpire', store, keys, expiration)
377
+ end
378
+
313
379
  def write_entity(key, value, store, options)
314
380
  namespaced = namespaced_key(key, options)
315
381
  dumped = entity.dump(value, options)
316
382
 
317
- if expiration = options[:expires_in]
318
- store.setex(namespaced, expiration.to_i, dumped)
383
+ if (expiration = options[:expires_in])
384
+ store.setex(namespaced, coerce_expiration(expiration), dumped)
319
385
  else
320
386
  store.set(namespaced, dumped)
321
387
  end
@@ -324,12 +390,15 @@ module Readthis
324
390
  private
325
391
 
326
392
  def alter(key, amount, options)
327
- number = read(key, options)
328
- delta = number.to_i + amount
393
+ delta = read(key, options).to_i + amount
329
394
  write(key, delta, options)
330
395
  delta
331
396
  end
332
397
 
398
+ def coerce_expiration(expires_in)
399
+ Float(expires_in).ceil
400
+ end
401
+
333
402
  def instrument(name, key)
334
403
  if self.class.notifications
335
404
  name = "cache_#{name}.active_support"
@@ -345,6 +414,8 @@ module Readthis
345
414
  instrument(operation, key) do
346
415
  pool.with(&block)
347
416
  end
417
+ rescue Redis::BaseError => error
418
+ raise error unless Readthis.fault_tolerant?
348
419
  end
349
420
 
350
421
  def extract_options!(array)
@@ -1,13 +1,19 @@
1
1
  require 'zlib'
2
2
 
3
3
  module Readthis
4
+ # An instance of the Entity class is used to handle `load` and `dump`
5
+ # operations on cached values.
4
6
  class Entity
7
+ # Unless they are overridden, these are the options used to load and unload
8
+ # every value.
5
9
  DEFAULT_OPTIONS = {
6
10
  compress: false,
7
11
  marshal: Marshal,
8
12
  threshold: 8 * 1024
9
13
  }.freeze
10
14
 
15
+ # A hexidecimal compression flag. When it is present within the magic bit
16
+ # of an entity that entity is considered compressed.
11
17
  COMPRESSED_FLAG = 0x8
12
18
 
13
19
  # Creates a Readthis::Entity with default options. Each option can be
@@ -17,9 +23,12 @@ module Readthis
17
23
  # automatically be used again when loading, regardless of how current
18
24
  # options are set.
19
25
  #
20
- # @option [Boolean] :compress (false) Enable or disable automatic compression
21
- # @option [Module] :marshal (Marshal) Any module that responds to `dump` and `load`
22
- # @option [Number] :threshold (8k) The size a string must be for compression
26
+ # @option [Boolean] :compress (false) Enable or disable automatic
27
+ # compression
28
+ # @option [Module] :marshal (Marshal) Any module that responds to `dump`
29
+ # and `load`
30
+ # @option [Number] :threshold (8k) The size a string must be for
31
+ # compression
23
32
  #
24
33
  def initialize(options = {})
25
34
  @options = DEFAULT_OPTIONS.merge(options)
@@ -28,10 +37,13 @@ module Readthis
28
37
  # Output a value prepared for cache storage. Passed options will override
29
38
  # whatever has been specified for the instance.
30
39
  #
31
- # @param [String] String to dump
32
- # @option [Boolean] :compress Enable or disable automatic compression
33
- # @option [Module] :marshal Any module that responds to `dump` and `load`
34
- # @option [Number] :threshold The size a string must be for compression
40
+ # @param [String] value String to dump
41
+ # @option options [Boolean] :compress Enable or disable automatic
42
+ # compression
43
+ # @option options [Module] :marshal Any module that responds to `dump` and
44
+ # `load`
45
+ # @option options [Number] :threshold The size a string must be for
46
+ # compression
35
47
  # @return [String] The prepared, possibly compressed, string
36
48
  #
37
49
  # @example Dumping a value using defaults
@@ -40,7 +52,7 @@ module Readthis
40
52
  #
41
53
  # @example Dumping a value with overrides
42
54
  #
43
- # entity.dump(string, compress: false)
55
+ # entity.dump(string, compress: false, marshal: JSON)
44
56
  #
45
57
  def dump(value, options = {})
46
58
  compress = with_fallback(options, :compress)
@@ -54,7 +66,7 @@ module Readthis
54
66
 
55
67
  # Parse a dumped value using the embedded options.
56
68
  #
57
- # @param [String] Option embedded string to load
69
+ # @param [String] string Option embedded string to load
58
70
  # @return [String] The original dumped string, restored
59
71
  #
60
72
  # @example
@@ -77,9 +89,9 @@ module Readthis
77
89
  # Where there are four unused bits, 1 compression bit, and 3 bits for the
78
90
  # serializer. This allows up to 8 different serializers for marshaling.
79
91
  #
80
- # @param [String] String to prefix with flags
81
- # @param [Module] The marshal module to be used
82
- # @param [Boolean] Flag determining whether the value is compressed
92
+ # @param [String] value String to prefix with flags
93
+ # @param [Module] marshal The marshal module to be used
94
+ # @param [Boolean] compress Flag determining whether the value is compressed
83
95
  # @return [String] The original string with a single byte prefixed
84
96
  #
85
97
  # @example Compose an option embedded string
@@ -96,7 +108,7 @@ module Readthis
96
108
 
97
109
  # Decompose an option embedded string into marshal, compression and value.
98
110
  #
99
- # @param [String] Option embedded string to
111
+ # @param [String] string Option embedded string to
100
112
  # @return [Array<Module, Boolean, String>] An array comprised of the
101
113
  # marshal, compression flag, and the base string.
102
114
  #
@@ -1,7 +1,22 @@
1
1
  module Readthis
2
+ # This is the base error that all other specific errors inherit from,
3
+ # making it possible to rescue the `ReadthisError` superclass.
4
+ #
5
+ # This isn't raised by itself.
2
6
  ReadthisError = Class.new(StandardError)
3
7
 
8
+ # Raised when attempting to modify the serializers after they have been
9
+ # frozen.
4
10
  SerializersFrozenError = Class.new(ReadthisError)
11
+
12
+ # Raised when attempting to add a new serializer after the limit of 7 is
13
+ # reached.
5
14
  SerializersLimitError = Class.new(ReadthisError)
15
+
16
+ # Raised when an unknown script is called.
17
+ UnknownCommandError = Class.new(ReadthisError)
18
+
19
+ # Raised when a serializer was specified, but hasn't been configured for
20
+ # usage.
6
21
  UnknownSerializerError = Class.new(ReadthisError)
7
22
  end
@@ -7,7 +7,10 @@ module Readthis
7
7
  when key.is_a?(Array)
8
8
  key.flat_map { |elem| expand_key(elem) }.join('/')
9
9
  when key.is_a?(Hash)
10
- key.sort_by { |hkey, _| hkey.to_s }.map { |hkey, val| "#{hkey}=#{val}" }.join('/')
10
+ key
11
+ .sort_by { |hkey, _| hkey.to_s }
12
+ .map { |hkey, val| "#{hkey}=#{val}" }
13
+ .join('/')
11
14
  when key.respond_to?(:to_param)
12
15
  key.to_param
13
16
  else
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Readthis
2
4
  module Passthrough
3
5
  def self.dump(value)
4
- value
6
+ value.dup
5
7
  end
6
8
 
7
9
  def self.load(value)
@@ -0,0 +1,56 @@
1
+ module Readthis
2
+ # The `Scripts` class is used to conveniently execute lua scripts. The first
3
+ # time a command is run it is stored on the server and subsequently referred
4
+ # to by its SHA. Each instance tracks SHAs separately, they are not global.
5
+ class Scripts
6
+ attr_reader :loaded
7
+
8
+ # Creates a new Readthis::Scripts instance.
9
+ def initialize
10
+ @loaded = {}
11
+ end
12
+
13
+ # Run a named lua script with the provided keys and arguments.
14
+ #
15
+ # @param [String] command The script to run, without a `.lua` extension
16
+ # @param [#Store] store A Redis client for storing and evaluating the script
17
+ # @param [Array] keys One or more keys to pass to the command
18
+ # @param [Array] args One or more args to pass to the command
19
+ #
20
+ # @return [Any] The Redis converted value returned on the script
21
+ #
22
+ # @example
23
+ #
24
+ # scripts.run('mexpire', store, %w[a b c], 1) # => 'OK'
25
+ #
26
+ def run(command, store, keys, args = [])
27
+ store.evalsha(
28
+ sha(command, store),
29
+ Array(keys),
30
+ Array(args)
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ def sha(command, store)
37
+ loaded[command] ||= load_script!(command, store)
38
+ end
39
+
40
+ def load_script!(command, store)
41
+ path = abs_path("#{command}.lua")
42
+
43
+ File.open(path) do |file|
44
+ loaded[command] = store.script(:load, file.read)
45
+ end
46
+ rescue Errno::ENOENT
47
+ raise Readthis::UnknownCommandError, "unknown command '#{command}'"
48
+ end
49
+
50
+ def abs_path(filename)
51
+ dir = File.expand_path(File.dirname(__FILE__))
52
+
53
+ File.join(dir, '../../script', filename)
54
+ end
55
+ end
56
+ end
@@ -4,12 +4,16 @@ require 'readthis/passthrough'
4
4
 
5
5
  module Readthis
6
6
  class Serializers
7
+ # Defines the default set of three serializers: Marshal, Passthrough, and
8
+ # JSON. With a hard limit of 7 that leaves 4 additional slots.
7
9
  BASE_SERIALIZERS = {
8
10
  Marshal => 0x1,
9
11
  Passthrough => 0x2,
10
12
  JSON => 0x3
11
13
  }.freeze
12
14
 
15
+ # The hard serializer limit, based on the number of possible values within
16
+ # a single 3bit integer.
13
17
  SERIALIZER_LIMIT = 7
14
18
 
15
19
  attr_reader :serializers, :inverted
@@ -25,7 +29,7 @@ module Readthis
25
29
  # any single application be configured for any single application. This
26
30
  # limit is based on the number of bytes available in the option flag.
27
31
  #
28
- # @param [Module] Any object that responds to `dump` and `load`
32
+ # @param [Module] serializer Any object that responds to `dump` and `load`
29
33
  # @return [self] Returns itself for possible chaining
30
34
  #
31
35
  # @example
@@ -36,9 +40,9 @@ module Readthis
36
40
  def <<(serializer)
37
41
  case
38
42
  when serializers.frozen?
39
- fail SerializersFrozenError
40
- when serializers.length > SERIALIZER_LIMIT
41
- fail SerializersLimitError
43
+ raise SerializersFrozenError
44
+ when serializers.length >= SERIALIZER_LIMIT
45
+ raise SerializersLimitError
42
46
  else
43
47
  @serializers[serializer] = flags.max.succ
44
48
  @inverted = @serializers.invert
@@ -63,7 +67,7 @@ module Readthis
63
67
 
64
68
  # Find a flag for a serializer object.
65
69
  #
66
- # @param [Object] Look up a flag by object
70
+ # @param [Object] serializer Look up a flag by object
67
71
  # @return [Number] Corresponding flag for the serializer object
68
72
  # @raise [UnknownSerializerError] Indicates that a serializer was
69
73
  # specified, but hasn't been configured for usage.
@@ -76,7 +80,7 @@ module Readthis
76
80
  flag = serializers[serializer]
77
81
 
78
82
  unless flag
79
- fail UnknownSerializerError, "'#{serializer}' hasn't been configured"
83
+ raise UnknownSerializerError, "'#{serializer}' hasn't been configured"
80
84
  end
81
85
 
82
86
  flag
@@ -84,7 +88,7 @@ module Readthis
84
88
 
85
89
  # Find a serializer object by flag value.
86
90
  #
87
- # @param [Number] Flag to look up the serializer object by
91
+ # @param [Number] flag Integer to look up the serializer object by
88
92
  # @return [Module] The serializer object
89
93
  #
90
94
  # @example
@@ -92,7 +96,7 @@ module Readthis
92
96
  # serializers.rassoc(1) #=> Marshal
93
97
  #
94
98
  def rassoc(flag)
95
- inverted[flag & inverted.length]
99
+ inverted[flag & SERIALIZER_LIMIT]
96
100
  end
97
101
 
98
102
  # @private
@@ -1,3 +1,3 @@
1
1
  module Readthis
2
- VERSION = '1.1.0'
2
+ VERSION = '1.5.0'.freeze
3
3
  end
@@ -0,0 +1,7 @@
1
+ local expire = ARGV[1]
2
+
3
+ for index = 1, #KEYS do
4
+ redis.call('EXPIRE', KEYS[index], expire)
5
+ end
6
+
7
+ return true
@@ -0,0 +1,13 @@
1
+ module RedisMatchers
2
+ extend RSpec::Matchers::DSL
3
+
4
+ matcher :have_ttl do |expected|
5
+ match do |cache|
6
+ cache.pool.with do |client|
7
+ expected.all? do |(key, value)|
8
+ client.ttl(key) == value
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,6 +1,8 @@
1
- require 'readthis'
1
+ require 'matchers/redis_matchers'
2
2
 
3
3
  RSpec.describe Readthis::Cache do
4
+ include RedisMatchers
5
+
4
6
  let(:cache) { Readthis::Cache.new }
5
7
 
6
8
  after do
@@ -48,16 +50,23 @@ RSpec.describe Readthis::Cache do
48
50
  end
49
51
 
50
52
  it 'uses a custom expiration' do
51
- cache = Readthis::Cache.new(namespace: 'cache', expires_in: 86400)
53
+ cache = Readthis::Cache.new(expires_in: 10)
52
54
 
53
55
  cache.write('some-key', 'some-value')
54
56
  cache.write('other-key', 'other-value', expires_in: 1)
55
57
 
56
58
  expect(cache.read('some-key')).not_to be_nil
57
59
  expect(cache.read('other-key')).not_to be_nil
58
- sleep 1.01
59
- expect(cache.read('some-key')).not_to be_nil
60
- expect(cache.read('other-key')).to be_nil
60
+
61
+ expect(cache).to have_ttl('some-key' => 10, 'other-key' => 1)
62
+ end
63
+
64
+ it 'rounds floats to a valid expiration value' do
65
+ cache = Readthis::Cache.new
66
+
67
+ cache.write('some-key', 'some-value', expires_in: 0.1)
68
+
69
+ expect(cache).to have_ttl('some-key' => 1)
61
70
  end
62
71
 
63
72
  it 'expands non-string keys' do
@@ -73,6 +82,18 @@ RSpec.describe Readthis::Cache do
73
82
  it 'gracefully handles nil options' do
74
83
  expect { cache.read('whatever', nil) }.not_to raise_error
75
84
  end
85
+
86
+ it 'can refresh the expiration of an entity' do
87
+ cache = Readthis::Cache.new(refresh: true)
88
+
89
+ cache.write('some-key', 'some-value', expires_in: 1)
90
+
91
+ cache.read('some-key', expires_in: 2)
92
+ expect(cache).to have_ttl('some-key' => 2)
93
+
94
+ cache.read('some-key', expires_in: 0.1)
95
+ expect(cache).to have_ttl('some-key' => 1)
96
+ end
76
97
  end
77
98
 
78
99
  describe 'serializers' do
@@ -151,6 +172,12 @@ RSpec.describe Readthis::Cache do
151
172
  expect(cache.read('missing-key')).to eq(value)
152
173
  end
153
174
 
175
+ it 'returns computed value when using passthrough marshalling' do
176
+ cache = Readthis::Cache.new(marshal: Readthis::Passthrough)
177
+ result = cache.fetch('missing-key') { 'value for you' }
178
+ expect(result).to eq('value for you')
179
+ end
180
+
154
181
  it 'does not set for a missing key without a block' do
155
182
  expect(cache.fetch('missing-key')).to be_nil
156
183
  end
@@ -166,6 +193,16 @@ RSpec.describe Readthis::Cache do
166
193
  cache.write('great-key', 'great')
167
194
  expect(cache.fetch('great-key', nil)).to eq('great')
168
195
  end
196
+
197
+ it 'serves computed content when the cache is down and tolerance is enabled' do
198
+ Readthis.fault_tolerant = true
199
+
200
+ allow(cache.pool).to receive(:with).and_raise(Redis::CannotConnectError)
201
+
202
+ computed = cache.fetch('error-key') { 'computed' }
203
+
204
+ expect(computed).to eq('computed')
205
+ end
169
206
  end
170
207
 
171
208
  describe '#read_multi' do
@@ -194,6 +231,17 @@ RSpec.describe Readthis::Cache do
194
231
  it 'returns {} with no keys' do
195
232
  expect(cache.read_multi(namespace: 'cache')).to eq({})
196
233
  end
234
+
235
+ it 'refreshes each key that is read' do
236
+ cache = Readthis::Cache.new(refresh: true)
237
+
238
+ cache.write('a', 1, expires_in: 1)
239
+ cache.write('b', 2, expires_in: 1)
240
+
241
+ cache.read_multi('a', 'b', expires_in: 2)
242
+
243
+ expect(cache).to have_ttl('a' => 2, 'b' => 2)
244
+ end
197
245
  end
198
246
 
199
247
  describe '#write_multi' do
@@ -214,8 +262,8 @@ RSpec.describe Readthis::Cache do
214
262
 
215
263
  expect(cache.read('a')).to be_nil
216
264
  expect(cache.read('a', namespace: 'multi')).to eq(1)
217
- sleep 1.01
218
- expect(cache.read('a', namespace: 'multi')).to be_nil
265
+
266
+ expect(cache).to have_ttl('multi:a' => 1)
219
267
  end
220
268
  end
221
269
 
@@ -279,6 +327,32 @@ RSpec.describe Readthis::Cache do
279
327
  end
280
328
  end
281
329
 
330
+ describe '#delete_matched' do
331
+ it 'deletes all matching keys' do
332
+ cache.write('tomcat', 'cat')
333
+ cache.write('wildcat', 'cat')
334
+ cache.write('bobcat', 'cat')
335
+ cache.write('cougar', 'cat')
336
+
337
+ expect(cache.delete_matched('tomcat')).to eq(1)
338
+ expect(cache.read('tomcat')).to be_nil
339
+ expect(cache.read('bobcat')).not_to be_nil
340
+ expect(cache.read('wildcat')).not_to be_nil
341
+
342
+ expect(cache.delete_matched('*cat', count: 1)).to eq(2)
343
+ expect(cache.read('wildcat')).to be_nil
344
+ expect(cache.read('bobcat')).to be_nil
345
+
346
+ expect(cache.delete_matched('*cat')).to eq(0)
347
+ end
348
+
349
+ it 'respects namespacing when matching keys' do
350
+ cache.write('tomcat', 'cat', namespace: 'feral')
351
+
352
+ expect(cache.delete_matched('tom*', namespace: 'feral')).to eq(1)
353
+ end
354
+ end
355
+
282
356
  describe '#increment' do
283
357
  it 'atomically increases the stored integer' do
284
358
  cache.write('counter', 10)
@@ -318,10 +392,10 @@ RSpec.describe Readthis::Cache do
318
392
  cache.read('a')
319
393
 
320
394
  expect(events.length).to eq(2)
321
- expect(events.map(&:name)).to eq(%w[
395
+ expect(events.map(&:name)).to eq %w[
322
396
  cache_write.active_support
323
397
  cache_read.active_support
324
- ])
398
+ ]
325
399
  end
326
400
  end
327
401
  end
@@ -1,4 +1,3 @@
1
- require 'readthis'
2
1
  require 'json'
3
2
 
4
3
  RSpec.describe Readthis::Entity do
@@ -1,5 +1,3 @@
1
- require 'readthis/expanders'
2
-
3
1
  RSpec.describe Readthis::Expanders do
4
2
  def expand(key, namespace = nil)
5
3
  Readthis::Expanders.namespace_key(key, namespace)
@@ -1,17 +1,16 @@
1
- require 'readthis/passthrough'
2
-
3
1
  RSpec.describe Readthis::Passthrough do
2
+ let(:value) { 'skywalker' }
3
+
4
4
  describe '.load' do
5
5
  it 'passes through the provided value' do
6
- value = Object.new
7
6
  expect(Readthis::Passthrough.load(value)).to eq(value)
8
7
  end
9
8
  end
10
9
 
11
10
  describe '.dump' do
12
11
  it 'passes through the provided value' do
13
- value = Object.new
14
12
  expect(Readthis::Passthrough.dump(value)).to eq(value)
13
+ expect(Readthis::Passthrough.dump(value)).not_to be(value)
15
14
  end
16
15
  end
17
16
  end
@@ -0,0 +1,31 @@
1
+ RSpec.describe Readthis::Scripts do
2
+ let(:scripts) { Readthis::Scripts.new }
3
+
4
+ describe '#run' do
5
+ it 'raises an error with an unknown command' do
6
+ expect do
7
+ scripts.run('unknown', nil, [])
8
+ end.to raise_error(Readthis::UnknownCommandError)
9
+ end
10
+
11
+ it 'runs the script command with a single key' do
12
+ store = Redis.new
13
+
14
+ store.set('alpha', 'content')
15
+ scripts.run('mexpire', store, 'alpha', 1)
16
+
17
+ expect(store.ttl('alpha')).to eq(1)
18
+ end
19
+
20
+ it 'runs the script command with multiple keys' do
21
+ store = Redis.new
22
+
23
+ store.set('beta', 'content')
24
+ store.set('gamma', 'content')
25
+ scripts.run('mexpire', store, %w[beta gamma], 1)
26
+
27
+ expect(store.ttl('beta')).to eq(1)
28
+ expect(store.ttl('gamma')).to eq(1)
29
+ end
30
+ end
31
+ end
@@ -1,5 +1,3 @@
1
- require 'readthis/serializers'
2
-
3
1
  RSpec.describe Readthis::Serializers do
4
2
  CustomSerializer = Class.new
5
3
  AnotherSerializer = Class.new
@@ -24,9 +22,9 @@ RSpec.describe Readthis::Serializers do
24
22
 
25
23
  it 'prevents more than seven serializers' do
26
24
  serializers = Readthis::Serializers.new
27
-
25
+ serializers << Class.new until serializers.flags.length >= 7
28
26
  expect do
29
- 10.times { serializers << Class.new }
27
+ serializers << Class.new
30
28
  end.to raise_error(Readthis::SerializersLimitError)
31
29
  end
32
30
  end
@@ -48,18 +46,29 @@ RSpec.describe Readthis::Serializers do
48
46
  end
49
47
 
50
48
  describe '#rassoc' do
51
- it 'inverts the current set of serializers' do
52
- serializers = Readthis::Serializers.new
49
+ let(:serializers) { Readthis::Serializers.new }
53
50
 
51
+ it 'inverts the current set of serializers' do
54
52
  expect(serializers.rassoc(1)).to eq(Marshal)
55
53
  end
56
54
 
57
55
  it 'returns custom serializers' do
58
- serializers = Readthis::Serializers.new
59
56
  serializers << CustomSerializer
60
-
61
57
  expect(serializers.rassoc(4)).to eq(CustomSerializer)
62
58
  end
59
+
60
+ it 'inverts default serializers after adding custom one' do
61
+ serializers << CustomSerializer
62
+ expect(serializers.rassoc(1)).to eq(Marshal)
63
+ expect(serializers.rassoc(3)).to eq(JSON)
64
+ end
65
+
66
+ it 'takes into account only first 3 bytes of passed integer' do
67
+ expect(serializers.rassoc(1)).to eq(Marshal)
68
+ expect(serializers.rassoc(11)).to eq(JSON)
69
+ serializers << CustomSerializer
70
+ expect(serializers.rassoc(12)).to eq(CustomSerializer)
71
+ end
63
72
  end
64
73
 
65
74
  describe '#freeze!' do
@@ -1,9 +1,19 @@
1
- require 'readthis'
2
-
3
1
  RSpec.describe Readthis do
4
2
  describe '#serializers' do
5
3
  it 'lists currently configured serializers' do
6
4
  expect(Readthis.serializers.marshals).to include(Marshal, JSON)
7
5
  end
8
6
  end
7
+
8
+ describe '#fault_tolerant?' do
9
+ it 'defaults to being false' do
10
+ expect(Readthis).not_to be_fault_tolerant
11
+ end
12
+
13
+ it 'can be enabled' do
14
+ Readthis.fault_tolerant = true
15
+
16
+ expect(Readthis).to be_fault_tolerant
17
+ end
18
+ end
9
19
  end
data/spec/spec_helper.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'coveralls'
2
+ require 'readthis'
2
3
 
3
4
  Coveralls.wear!
4
5
 
@@ -20,4 +21,8 @@ RSpec.configure do |config|
20
21
 
21
22
  config.order = :random
22
23
  Kernel.srand config.seed
24
+
25
+ config.before do
26
+ Readthis.reset!
27
+ end
23
28
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: readthis
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Parker Selbert
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-12-08 00:00:00.000000000 Z
11
+ date: 2016-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -123,12 +123,16 @@ files:
123
123
  - lib/readthis/errors.rb
124
124
  - lib/readthis/expanders.rb
125
125
  - lib/readthis/passthrough.rb
126
+ - lib/readthis/scripts.rb
126
127
  - lib/readthis/serializers.rb
127
128
  - lib/readthis/version.rb
129
+ - script/mexpire.lua
130
+ - spec/matchers/redis_matchers.rb
128
131
  - spec/readthis/cache_spec.rb
129
132
  - spec/readthis/entity_spec.rb
130
133
  - spec/readthis/expanders_spec.rb
131
134
  - spec/readthis/passthrough_spec.rb
135
+ - spec/readthis/scripts_spec.rb
132
136
  - spec/readthis/serializers_spec.rb
133
137
  - spec/readthis_spec.rb
134
138
  - spec/spec_helper.rb
@@ -152,15 +156,18 @@ required_rubygems_version: !ruby/object:Gem::Requirement
152
156
  version: '0'
153
157
  requirements: []
154
158
  rubyforge_project:
155
- rubygems_version: 2.4.5.1
159
+ rubygems_version: 2.5.1
156
160
  signing_key:
157
161
  specification_version: 4
158
162
  summary: Pooled active support compliant caching with redis
159
163
  test_files:
164
+ - spec/matchers/redis_matchers.rb
160
165
  - spec/readthis/cache_spec.rb
161
166
  - spec/readthis/entity_spec.rb
162
167
  - spec/readthis/expanders_spec.rb
163
168
  - spec/readthis/passthrough_spec.rb
169
+ - spec/readthis/scripts_spec.rb
164
170
  - spec/readthis/serializers_spec.rb
165
171
  - spec/readthis_spec.rb
166
172
  - spec/spec_helper.rb
173
+ has_rdoc: