readthis 0.8.1 → 1.0.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: ea629e7d22f58baa04359d6fabaee9116caf4e5d
4
- data.tar.gz: ff5720742c337be8b178ca840064b3469766dcda
3
+ metadata.gz: 985ab4cb70b6ff45de0b279fe4d3ca90690cb206
4
+ data.tar.gz: d3c0519b74366c12278dc2a554d0a33c2914e7ac
5
5
  SHA512:
6
- metadata.gz: b75d0eb316bb68975693ab64044cc3c30159b9511dde446b504e1075b5c6aa915bc3782290334001de3b921173714343c86110778bc41ec90f67dc84b9ef0704
7
- data.tar.gz: 765fae41b2f8d511cd99269e17ed31eacbb968239e97be78422a4b20793ae3325feefc2f19d411b6d0a17722a7b06bbba78230fe49e12c76e01edabb9fc93b43
6
+ metadata.gz: e0cec4bef654487ed5f99c46f27d05572993a823239d1993b6e0b534f0b699f170c773ef191cddf468e1b05936c588d23ac6716ddf7659c89a86710306f13c5b
7
+ data.tar.gz: 5f8ac666204946a58135953c5e4b23d64a12f412019d578b28c8b8b1a7c351c1004775c0470a812038b12d2577b87eec5df8a4e09f628e5fbb1bed3fc008517a
data/README.md CHANGED
@@ -52,7 +52,11 @@ cache = Readthis::Cache.new(
52
52
  )
53
53
  ```
54
54
 
55
+ You can also specify `host`, `port`, `db` or any other valid Redis options. For
56
+ more details about connection options see in [redis gem documentation][redisrb]
57
+
55
58
  [store]: http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html
59
+ [redisrb]: https://github.com/redis/redis-rb#getting-started
56
60
 
57
61
  ### Instances & Databases
58
62
 
@@ -96,25 +100,43 @@ config.cache_store = :readthis_store, {
96
100
  }
97
101
  ```
98
102
 
99
- ### Marshalling
103
+ ### Serializing
100
104
 
101
- Readthis uses Ruby's `Marshal` module for dumping and loading all values by
102
- default. This isn't always the fastest option, and depending on your use case it
103
- may be desirable to use a faster but less flexible marshaller.
105
+ Readthis uses Ruby's `Marshal` module for serializing all values by default.
106
+ This isn't always the fastest option, and depending on your use case it may be
107
+ desirable to use a faster but less flexible serializer.
104
108
 
105
- Use Oj for JSON marshalling, extremely fast, but supports limited types:
109
+ By default Readthis knows about 3 different serializers:
106
110
 
107
- ```ruby
108
- Readthis::Cache.new(marshal: Oj)
109
- ```
111
+ * Marshal
112
+ * JSON
113
+ * Passthrough
110
114
 
111
115
  If all cached data can safely be represented as a string then use the
112
- pass-through marshaller:
116
+ pass-through serializer:
113
117
 
114
118
  ```ruby
115
119
  Readthis::Cache.new(marshal: Readthis::Passthrough)
116
120
  ```
117
121
 
122
+ You can introduce up to four additional serializers by configuring `serializers`
123
+ on the Readthis module. For example, if you wanted to use the extremely fast Oj
124
+ library for JSON serialization:
125
+
126
+ ```ruby
127
+ Readthis.serializers << Oj
128
+
129
+ # Freeze the serializers to ensure they aren't changed at runtime.
130
+ Readthis.serializers.freeze!
131
+
132
+ Readthis::Cache.new(marshal: Oj)
133
+ ```
134
+
135
+ Be aware that the order in which you add serializers matters. Serializers are
136
+ sticky and a flag is stored with each cached value. If you subsequently go to
137
+ deserialize values and haven't configured the same serializers in the same order
138
+ your application will raise errors.
139
+
118
140
  ## Differences From ActiveSupport::Cache
119
141
 
120
142
  Readthis supports all of standard cache methods except for the following:
@@ -2,6 +2,6 @@ require 'readthis'
2
2
 
3
3
  module ActiveSupport
4
4
  module Cache
5
- ReadthisStore ||= Readthis::Cache
5
+ ReadthisStore ||= Readthis::Cache # rubocop:disable Style/ConstantName
6
6
  end
7
7
  end
@@ -1,24 +1,19 @@
1
1
  require 'readthis/entity'
2
2
  require 'readthis/expanders'
3
- require 'readthis/notifications'
4
3
  require 'readthis/passthrough'
5
4
  require 'redis'
6
5
  require 'connection_pool'
7
6
 
8
7
  module Readthis
9
8
  class Cache
10
- attr_reader :entity, :expires_in, :namespace, :options, :pool
9
+ attr_reader :entity, :notifications, :options, :pool
11
10
 
12
11
  # Provide a class level lookup of the proper notifications module.
13
12
  # Instrumention is expected to occur within applications that have
14
13
  # ActiveSupport::Notifications available, but needs to work even when it
15
14
  # isn't.
16
15
  def self.notifications
17
- if defined?(ActiveSupport::Notifications)
18
- ActiveSupport::Notifications
19
- else
20
- Readthis::Notifications
21
- end
16
+ ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
22
17
  end
23
18
 
24
19
  # Creates a new Readthis::Cache object with the given options.
@@ -39,9 +34,7 @@ module Readthis
39
34
  # Readthis::Cache.new(compress: true, compression_threshold: 2048)
40
35
  #
41
36
  def initialize(options = {})
42
- @options = options
43
- @expires_in = options.fetch(:expires_in, nil)
44
- @namespace = options.fetch(:namespace, nil)
37
+ @options = options
45
38
 
46
39
  @entity = Readthis::Entity.new(
47
40
  marshal: options.fetch(:marshal, Marshal),
@@ -171,7 +164,7 @@ module Readthis
171
164
  # cache.increment('counter', 2) # => 3
172
165
  #
173
166
  def increment(key, amount = 1, options = {})
174
- invoke(:incremenet, key) do |store|
167
+ invoke(:increment, key) do |_store|
175
168
  alter(key, amount, options)
176
169
  end
177
170
  end
@@ -192,7 +185,7 @@ module Readthis
192
185
  # cache.decrement('counter', 2) # => 17
193
186
  #
194
187
  def decrement(key, amount = 1, options = {})
195
- invoke(:decrement, key) do |store|
188
+ invoke(:decrement, key) do |_store|
196
189
  alter(key, amount * -1, options)
197
190
  end
198
191
  end
@@ -271,13 +264,13 @@ module Readthis
271
264
  extracted = extract_options!(keys)
272
265
  missing = {}
273
266
 
274
- invoke(:fetch_multi, keys) do |store|
267
+ invoke(:fetch_multi, keys) do |_store|
275
268
  results.each do |key, value|
276
- if value.nil?
277
- value = yield(key)
278
- missing[key] = value
279
- results[key] = value
280
- end
269
+ next unless value.nil?
270
+
271
+ value = yield(key)
272
+ missing[key] = value
273
+ results[key] = value
281
274
  end
282
275
  end
283
276
 
@@ -310,7 +303,7 @@ module Readthis
310
303
  # @example
311
304
  #
312
305
  # cache.clear #=> 'OK'
313
- def clear(options = {})
306
+ def clear(_options = nil)
314
307
  invoke(:clear, '*', &:flushdb)
315
308
  end
316
309
 
@@ -318,11 +311,12 @@ module Readthis
318
311
 
319
312
  def write_entity(key, value, store, options)
320
313
  namespaced = namespaced_key(key, options)
314
+ dumped = entity.dump(value, options)
321
315
 
322
316
  if expiration = options[:expires_in]
323
- store.setex(namespaced, expiration.to_i, entity.dump(value))
317
+ store.setex(namespaced, expiration.to_i, dumped)
324
318
  else
325
- store.set(namespaced, entity.dump(value))
319
+ store.set(namespaced, dumped)
326
320
  end
327
321
  end
328
322
 
@@ -335,11 +329,15 @@ module Readthis
335
329
  delta
336
330
  end
337
331
 
338
- def instrument(operation, key)
339
- name = "cache_#{operation}.active_support"
340
- payload = { key: key }
332
+ def instrument(name, key)
333
+ if self.class.notifications
334
+ name = "cache_#{name}.active_support"
335
+ payload = { key: key, name: name }
341
336
 
342
- self.class.notifications.instrument(name, key) { yield(payload) }
337
+ self.class.notifications.instrument(name, payload) { yield(payload) }
338
+ else
339
+ yield
340
+ end
343
341
  end
344
342
 
345
343
  def invoke(operation, key, &block)
@@ -353,10 +351,7 @@ module Readthis
353
351
  end
354
352
 
355
353
  def merged_options(options)
356
- options = options || {}
357
- options[:namespace] ||= namespace
358
- options[:expires_in] ||= expires_in
359
- options
354
+ (options || {}).merge!(@options)
360
355
  end
361
356
 
362
357
  def pool_options(options)
@@ -2,51 +2,143 @@ require 'zlib'
2
2
 
3
3
  module Readthis
4
4
  class Entity
5
- DEFAULT_THRESHOLD = 8 * 1024
6
- MAGIC_BYTES = [120, 156].freeze
5
+ DEFAULT_OPTIONS = {
6
+ compress: false,
7
+ marshal: Marshal,
8
+ threshold: 8 * 1024
9
+ }.freeze
7
10
 
8
- attr_reader :marshal, :compression, :threshold
11
+ COMPRESSED_FLAG = 0x8
9
12
 
13
+ # Creates a Readthis::Entity with default options. Each option can be
14
+ # overridden later when entities are being dumped.
15
+ #
16
+ # Options are sticky, meaning that whatever is used when dumping will
17
+ # automatically be used again when loading, regardless of how current
18
+ # options are set.
19
+ #
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
23
+ #
10
24
  def initialize(options = {})
11
- @marshal = options.fetch(:marshal, Marshal)
12
- @compression = options.fetch(:compress, false)
13
- @threshold = options.fetch(:threshold, DEFAULT_THRESHOLD)
25
+ @options = DEFAULT_OPTIONS.merge(options)
14
26
  end
15
27
 
16
- def dump(value)
17
- if compress?(value)
18
- compress(value)
19
- else
20
- marshal.dump(value)
21
- end
28
+ # Output a value prepared for cache storage. Passed options will override
29
+ # whatever has been specified for the instance.
30
+ #
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
35
+ # @return [String] The prepared, possibly compressed, string
36
+ #
37
+ # @example Dumping a value using defaults
38
+ #
39
+ # entity.dump(string)
40
+ #
41
+ # @example Dumping a value with overrides
42
+ #
43
+ # entity.dump(string, compress: false)
44
+ #
45
+ def dump(value, options = {})
46
+ compress = with_fallback(options, :compress)
47
+ marshal = with_fallback(options, :marshal)
48
+ threshold = with_fallback(options, :threshold)
49
+
50
+ dumped = deflate(marshal.dump(value), compress, threshold)
51
+
52
+ compose(dumped, marshal, compress)
22
53
  end
23
54
 
24
- def load(value)
25
- if compressed?(value)
26
- decompress(value)
27
- else
28
- marshal.load(value)
29
- end
30
- rescue TypeError, Zlib::Error
31
- value
55
+ # Parse a dumped value using the embedded options.
56
+ #
57
+ # @param [String] Option embedded string to load
58
+ # @return [String] The original dumped string, restored
59
+ #
60
+ # @example
61
+ #
62
+ # entity.load(dumped)
63
+ #
64
+ def load(string)
65
+ marshal, compress, value = decompose(string)
66
+
67
+ marshal.load(inflate(value, compress))
68
+ rescue TypeError, NoMethodError
69
+ string
32
70
  end
33
71
 
34
- def compress(value)
35
- Zlib::Deflate.deflate(marshal.dump(value))
72
+ # Composes a single byte comprised of the chosen serializer and compression
73
+ # options. The byte is formatted as:
74
+ #
75
+ # | 0000 | 0 | 000 |
76
+ #
77
+ # Where there are four unused bits, 1 compression bit, and 3 bits for the
78
+ # serializer. This allows up to 8 different serializers for marshaling.
79
+ #
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
83
+ # @return [String] The original string with a single byte prefixed
84
+ #
85
+ # @example Compose an option embedded string
86
+ #
87
+ # entity.compose(string, Marshal, false) => 0x1 + string
88
+ # entity.compose(string, JSON, true) => 0x10 + string
89
+ #
90
+ def compose(value, marshal, compress)
91
+ flags = serializers.assoc(marshal)
92
+ flags |= COMPRESSED_FLAG if compress
93
+
94
+ value.prepend([flags].pack('C'))
36
95
  end
37
96
 
38
- def decompress(value)
39
- marshal.load(Zlib::Inflate.inflate(value))
97
+ # Decompose an option embedded string into marshal, compression and value.
98
+ #
99
+ # @param [String] Option embedded string to
100
+ # @return [Array<Module, Boolean, String>] An array comprised of the
101
+ # marshal, compression flag, and the base string.
102
+ #
103
+ def decompose(string)
104
+ flags = string[0].unpack('C').first
105
+
106
+ if flags < 16
107
+ marshal = serializers.rassoc(flags)
108
+ compress = (flags & COMPRESSED_FLAG) != 0
109
+
110
+ [marshal, compress, string[1..-1]]
111
+ else
112
+ [@options[:marshal], @options[:compress], string]
113
+ end
40
114
  end
41
115
 
42
116
  private
43
117
 
44
- def compress?(value)
45
- compression && value.bytesize >= threshold
118
+ def deflate(value, compress, threshold)
119
+ if compress && value.bytesize >= threshold
120
+ Zlib::Deflate.deflate(value)
121
+ else
122
+ value
123
+ end
124
+ end
125
+
126
+ def inflate(value, decompress)
127
+ if decompress
128
+ Zlib::Inflate.inflate(value)
129
+ else
130
+ value
131
+ end
132
+ rescue Zlib::Error
133
+ value
134
+ end
135
+
136
+ def serializers
137
+ Readthis.serializers
46
138
  end
47
139
 
48
- def compressed?(value)
49
- compression && value[0, 2].unpack('CC') == MAGIC_BYTES
140
+ def with_fallback(options, key)
141
+ options.key?(key) ? options[key] : @options[key]
50
142
  end
51
143
  end
52
144
  end
@@ -7,7 +7,7 @@ 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 { |key, _| key.to_s }.map { |key, val| "#{key}=#{val}" }.join('/')
10
+ key.sort_by { |hkey, _| hkey.to_s }.map { |hkey, val| "#{hkey}=#{val}" }.join('/')
11
11
  when key.respond_to?(:to_param)
12
12
  key.to_param
13
13
  else
@@ -0,0 +1,111 @@
1
+ require 'json'
2
+ require 'readthis/passthrough'
3
+
4
+ module Readthis
5
+ SerializersFrozenError = Class.new(StandardError)
6
+ SerializersLimitError = Class.new(StandardError)
7
+ UnknownSerializerError = Class.new(StandardError)
8
+
9
+ class Serializers
10
+ BASE_SERIALIZERS = {
11
+ Marshal => 0x1,
12
+ Passthrough => 0x2,
13
+ JSON => 0x3
14
+ }.freeze
15
+
16
+ SERIALIZER_LIMIT = 7
17
+
18
+ attr_reader :serializers, :inverted
19
+
20
+ # Creates a new Readthis::Serializers entity. No configuration is expected
21
+ # during initialization.
22
+ #
23
+ def initialize
24
+ reset!
25
+ end
26
+
27
+ # Append a new serializer. Up to 7 total serializers may be configured for
28
+ # any single application be configured for any single application. This
29
+ # limit is based on the number of bytes available in the option flag.
30
+ #
31
+ # @param [Module] Any object that responds to `dump` and `load`
32
+ # @return [self] Returns itself for possible chaining
33
+ #
34
+ # @example
35
+ #
36
+ # serializers = Readthis::Serializers.new
37
+ # serializers << Oj
38
+ #
39
+ def <<(serializer)
40
+ case
41
+ when serializers.frozen?
42
+ fail SerializersFrozenError
43
+ when serializers.length > SERIALIZER_LIMIT
44
+ fail SerializersLimitError
45
+ else
46
+ @serializers[serializer] = flags.max.succ
47
+ @inverted = @serializers.invert
48
+ end
49
+
50
+ self
51
+ end
52
+
53
+ # Freeze the serializers hash, preventing modification.
54
+ #
55
+ def freeze!
56
+ serializers.freeze
57
+ end
58
+
59
+ # Reset the instance back to the default state. Useful for cleanup during
60
+ # testing.
61
+ #
62
+ def reset!
63
+ @serializers = BASE_SERIALIZERS.dup
64
+ @inverted = @serializers.invert
65
+ end
66
+
67
+ # Find a flag for a serializer object.
68
+ #
69
+ # @param [Object] Look up a flag by object
70
+ # @return [Number] Corresponding flag for the serializer object
71
+ # @raise [UnknownSerializerError] Indicates that a serializer was
72
+ # specified, but hasn't been configured for usage.
73
+ #
74
+ # @example
75
+ #
76
+ # serializers.assoc(JSON) #=> 1
77
+ #
78
+ def assoc(serializer)
79
+ flag = serializers[serializer]
80
+
81
+ unless flag
82
+ fail UnknownSerializerError, "'#{serializer}' hasn't been configured"
83
+ end
84
+
85
+ flag
86
+ end
87
+
88
+ # Find a serializer object by flag value.
89
+ #
90
+ # @param [Number] Flag to look up the serializer object by
91
+ # @return [Module] The serializer object
92
+ #
93
+ # @example
94
+ #
95
+ # serializers.rassoc(1) #=> Marshal
96
+ #
97
+ def rassoc(flag)
98
+ inverted[flag & inverted.length]
99
+ end
100
+
101
+ # @private
102
+ def marshals
103
+ serializers.keys
104
+ end
105
+
106
+ # @private
107
+ def flags
108
+ serializers.values
109
+ end
110
+ end
111
+ end
@@ -1,3 +1,3 @@
1
1
  module Readthis
2
- VERSION = '0.8.1'
2
+ VERSION = '1.0.0'
3
3
  end
data/lib/readthis.rb CHANGED
@@ -1,5 +1,11 @@
1
1
  require 'readthis/cache'
2
+ require 'readthis/serializers'
2
3
  require 'readthis/version'
3
4
 
4
5
  module Readthis
6
+ extend self
7
+
8
+ def serializers
9
+ @serializers ||= Readthis::Serializers.new
10
+ end
5
11
  end
@@ -1,4 +1,4 @@
1
- require 'readthis/cache'
1
+ require 'readthis'
2
2
 
3
3
  RSpec.describe Readthis::Cache do
4
4
  let(:cache) { Readthis::Cache.new }
@@ -8,18 +8,6 @@ RSpec.describe Readthis::Cache do
8
8
  end
9
9
 
10
10
  describe '#initialize' do
11
- it 'accepts and persists a namespace' do
12
- cache = Readthis::Cache.new(namespace: 'kash')
13
-
14
- expect(cache.namespace).to eq('kash')
15
- end
16
-
17
- it 'accepts and persists an expiration' do
18
- cache = Readthis::Cache.new(expires_in: 10)
19
-
20
- expect(cache.expires_in).to eq(10)
21
- end
22
-
23
11
  it 'makes options available' do
24
12
  cache = Readthis::Cache.new(namespace: 'cache', expires_in: 1)
25
13
 
@@ -82,19 +70,52 @@ RSpec.describe Readthis::Cache do
82
70
  end
83
71
  end
84
72
 
73
+ describe 'serializers' do
74
+ after do
75
+ Readthis.serializers.reset!
76
+ end
77
+
78
+ it 'uses globally configured serializers' do
79
+ custom = Class.new do
80
+ def self.dump(value)
81
+ value
82
+ end
83
+
84
+ def self.load(value)
85
+ value
86
+ end
87
+ end
88
+
89
+ Readthis.serializers << custom
90
+
91
+ cache.write('customized', 'some value', marshal: custom)
92
+
93
+ expect(cache.read('customized')).to eq('some value')
94
+ end
95
+ end
96
+
85
97
  describe 'compression' do
86
- it 'round trips entries when compression is enabled' do
98
+ it 'roundtrips entries when compression is enabled' do
87
99
  com_cache = Readthis::Cache.new(compress: true, compression_threshold: 8)
88
100
  raw_cache = Readthis::Cache.new
89
101
  value = 'enough text that it should be compressed'
90
102
 
91
103
  com_cache.write('compressed', value)
92
104
 
93
- expect(raw_cache.read('compressed')).not_to eq(value)
94
105
  expect(com_cache.read('compressed')).to eq(value)
106
+ expect(raw_cache.read('compressed')).to eq(value)
107
+ end
108
+
109
+ it 'roundtrips entries with option overrides' do
110
+ cache = Readthis::Cache.new(compress: false)
111
+ value = 'enough text that it should be compressed'
112
+
113
+ cache.write('comp-round', value, marshal: JSON, compress: true, threshold: 8)
114
+
115
+ expect(cache.read('comp-round')).to eq(value)
95
116
  end
96
117
 
97
- it 'round trips bulk entries when compression is enabled' do
118
+ it 'roundtrips bulk entries when compression is enabled' do
98
119
  cache = Readthis::Cache.new(compress: true, compression_threshold: 8)
99
120
  value = 'also enough text to compress'
100
121
 
@@ -146,7 +167,7 @@ RSpec.describe Readthis::Cache do
146
167
  expect(cache.read_multi('a', 'b', 'c')).to eq(
147
168
  'a' => 1,
148
169
  'b' => 2,
149
- 'c' => '3',
170
+ 'c' => '3'
150
171
  )
151
172
  end
152
173
 
@@ -156,7 +177,7 @@ RSpec.describe Readthis::Cache do
156
177
 
157
178
  expect(cache.read_multi('d', 'e', namespace: 'cache')).to eq(
158
179
  'd' => 1,
159
- 'e' => 2,
180
+ 'e' => 2
160
181
  )
161
182
  end
162
183
 
@@ -175,7 +196,11 @@ RSpec.describe Readthis::Cache do
175
196
  end
176
197
 
177
198
  it 'respects passed options' do
178
- cache.write_multi({ 'a' => 1, 'b' => 2 }, namespace: 'multi', expires_in: 1)
199
+ cache.write_multi(
200
+ { 'a' => 1, 'b' => 2 },
201
+ namespace: 'multi',
202
+ expires_in: 1
203
+ )
179
204
 
180
205
  expect(cache.read('a')).to be_nil
181
206
  expect(cache.read('a', namespace: 'multi')).to eq(1)
@@ -194,7 +219,7 @@ RSpec.describe Readthis::Cache do
194
219
  expect(results).to eq(
195
220
  'a' => 1,
196
221
  'b' => 'bb',
197
- 'c' => 3,
222
+ 'c' => 3
198
223
  )
199
224
 
200
225
  expect(cache.read('b')).to eq('bb')
@@ -266,4 +291,27 @@ RSpec.describe Readthis::Cache do
266
291
  expect(cache.decrement('unknown')).to eq(-1)
267
292
  end
268
293
  end
294
+
295
+ describe 'instrumentation' do
296
+ it 'instruments cache invokations' do
297
+ require 'active_support/notifications'
298
+
299
+ notes = ActiveSupport::Notifications
300
+ cache = Readthis::Cache.new
301
+ events = []
302
+
303
+ notes.subscribe(/cache_*/) do |*args|
304
+ events << ActiveSupport::Notifications::Event.new(*args)
305
+ end
306
+
307
+ cache.write('a', 'a')
308
+ cache.read('a')
309
+
310
+ expect(events.length).to eq(2)
311
+ expect(events.map(&:name)).to eq(%w[
312
+ cache_write.active_support
313
+ cache_read.active_support
314
+ ])
315
+ end
316
+ end
269
317
  end