atomic_cache 0.2.1.rc2 → 0.3.0.rc1

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
  SHA256:
3
- metadata.gz: 89d82475dddde4fb324d6f2187d47122916dc381883fedcfec2ff00204035e08
4
- data.tar.gz: 34d5d998acee788561e3e49019d5983fed4226e4cf88ba7cb0d8fe7cb2bd5a3b
3
+ metadata.gz: becad2b31944cbd2f93353a4b5e015f8f39c6f508f605f3adcd672fd7e8482b9
4
+ data.tar.gz: a76b093ad299c779adabe12053731f6582eca33b169a4b05c3a2c619f8747855
5
5
  SHA512:
6
- metadata.gz: e19f7c43629d01ebeceba040c818a8ae75f183245e9171d3ab39a3d302388769f263292ae04d39c1dcc01137190181f27f5628b3f091a26808c1767ce85ba24e
7
- data.tar.gz: b5d0036456849d3d882227231908da8e885e1e6772f4e0b1a4b121a3690e34acd0f0b4ce5a67816c5fc62b5e053100d8c93980b0e8252f693f8737e42bed60e7
6
+ metadata.gz: 71ea9a439a06adde2a837132820555a564aa2168344a9b9b455fcf04e3293aa3ce147ca9a534ed97266dda92a1db09aa0cc33af22262c6997f8dca334ac0ec66
7
+ data.tar.gz: 3e4c87333014f120f9ef8055492886734525cbc85ef5245fe87c32a3e908ce0ab059f5ea297a6bc3c2c05517c294ac6ee7891e0caa3d3d4f44db83ea3583fc64
data/README.md CHANGED
@@ -23,7 +23,7 @@ In a nutshell:
23
23
  class Foo < ActiveRecord::Base
24
24
  include AtomicCache::GlobalLMTCacheConcern
25
25
 
26
- cache_class(:custom_foo) # optional
26
+ force_cache_class(:custom_foo) # optional
27
27
  cache_version(5) # optional
28
28
 
29
29
  def active_foos(ids)
data/docs/MODEL_SETUP.md CHANGED
@@ -7,13 +7,13 @@ class Foo < ActiveRecord::Base
7
7
  end
8
8
  ```
9
9
 
10
- ### cache_class
10
+ ### force_cache_class
11
11
  By default the cache identifier for a class is set to the name of a class (ie. `self.to_s`). In some cases it makes sense to set a custom value for the cache identifier. In cases where a custom cache identifier is set, it's important that the identifier remain unique across the project.
12
12
 
13
13
  ```ruby
14
14
  class SuperDescriptiveDomainModelAbstractFactoryImplManager < ActiveRecord::Base
15
15
  include AtomicCache::GlobalLMTCacheConcern
16
- cache_class('sddmafim')
16
+ force_cache_class('sddmafim')
17
17
  end
18
18
  ```
19
19
 
@@ -23,9 +23,10 @@ AtomicCache::DefaultConfig.configure do |config|
23
23
  config.metrics = Datadog::Statsd.new('localhost', 8125, namespace: 'cache.atomic')
24
24
 
25
25
  # note: these values can also be set in an env file for env-specific settings
26
- config.namespace = 'atom'
27
- config.cache_storage = AtomicCache::Storage::SharedMemory.new
28
- config.key_storage = AtomicCache::Storage::SharedMemory.new
26
+ config.namespace = 'atom'
27
+ config.default_options = { generate_ttl_ms: 500 }
28
+ config.cache_storage = AtomicCache::Storage::SharedMemory.new
29
+ config.key_storage = AtomicCache::Storage::SharedMemory.new
29
30
  end
30
31
  ```
31
32
 
@@ -36,7 +37,7 @@ Note that `Datadog::Statsd` is not _required_. Adding it, however, will enable
36
37
  * `key_storage` - Storage adapter for key manager (see below)
37
38
 
38
39
  #### Optional
39
- * `default_options` - Default options for every fetch call. See [fetch options](/Ibotta/atomic_cache/blob/main/docs/USAGE.md#fetch).
40
+ * `default_options` - Override default options for every fetch call, unless specified at call site. See [fetch options](/Ibotta/atomic_cache/blob/main/docs/USAGE.md#fetch).
40
41
  * `logger` - Logger instance. Used for debug and warn logs. Defaults to nil.
41
42
  * `timestamp_formatter` - Proc to format last modified time for storage. Defaults to timestamp (`Time.to_i`)
42
43
  * `metrics` - Metrics instance. Defaults to nil.
@@ -45,6 +46,49 @@ Note that `Datadog::Statsd` is not _required_. Adding it, however, will enable
45
46
  #### ★ Best Practice ★
46
47
  Keep the global namespace short. For example, memcached has a limit of 250 characters for key length.
47
48
 
49
+ #### More Complex Rails Configuration
50
+
51
+ In any real-world project, the need to run multiple caching strategies or setups is likely to arise. In those cases, it's often advantageous
52
+ to keep a DRY setup, with multiple caching clients sharing the same config. Because Rails initializers run after the environment-specific
53
+ config files, a sane way to manage this is to keep client network settings int he config files, then reference them from the initializer.
54
+
55
+ ```ruby
56
+ # config/environments/staging
57
+ config.memcache_hosts = [ "staging.host.cache.amazonaws.com" ]
58
+ config.cache_store_options = {
59
+ expires_in: 15.minutes,
60
+ compress: true,
61
+ # ...
62
+ }
63
+
64
+ # config/environments/production
65
+ config.memcache_hosts = [ "prod1.host.cache.amazonaws.com", "prod2.host.cache.amazonaws.com" ]
66
+ config.cache_store_options = {
67
+ expires_in: 1.hour,
68
+ compress: true,
69
+ # ...
70
+ }
71
+
72
+ # config/initializers/cache.rb
73
+ AtomicCache::DefaultConfig.configure do |config|
74
+ if Rails.env.development? || Rails.env.test?
75
+ config.cache_storage = AtomicCache::Storage::SharedMemory.new
76
+ config.key_storage = AtomicCache::Storage::SharedMemory.new
77
+
78
+ elsif Rails.env.staging? || Rails.env.production?
79
+ # Your::Application.config will be loaded by config/environments/*
80
+ memcache_hosts = Your::Application.config.memcache_hosts
81
+ options = Your::Application.config.cache_store_options
82
+
83
+ dc = Dalli::Client.new(memcache_hosts, options)
84
+ config.cache_storage = AtomicCache::Storage::Dalli.new(dc)
85
+ config.key_storage = AtomicCache::Storage::Dalli.new(dc)
86
+ end
87
+
88
+ # other AtomicCache configuration...
89
+ end
90
+ ```
91
+
48
92
  ## Storage Adapters
49
93
 
50
94
  ### InstanceMemory & SharedMemory
data/docs/USAGE.md CHANGED
@@ -21,6 +21,14 @@ end
21
21
 
22
22
  In addition to the below options, any other options given (e.g. `expires_in`, `cache_nils`) are passed through to the underlying storage adapter. This allows storage-specific options to be passed through (reference: [Dalli config](https://github.com/petergoldstein/dalli#configuration)).
23
23
 
24
+ #### TTL
25
+ Various storage clients require TTL to be expressed in different ways. The included storage adapters will unwrap the `ttl` option to an storage-specific representation.
26
+ ```ruby
27
+ atomic_cache.fetch(ttl: 500) do
28
+ # generate block
29
+ end
30
+ ```
31
+
24
32
  #### `generate_ttl_ms`
25
33
  _Defaults to 30 seconds._
26
34
 
@@ -27,7 +27,6 @@ module AtomicCache
27
27
  raise ArgumentError.new("`storage` required but none given") unless @storage.present?
28
28
  end
29
29
 
30
-
31
30
  # Attempts to fetch the given keyspace, using an optional block to generate
32
31
  # a new value when the cache is expired
33
32
  #
@@ -45,6 +44,7 @@ module AtomicCache
45
44
  value = @storage.read(key, options) if key.present?
46
45
  if !value.nil?
47
46
  metrics(:increment, 'read.present', tags: tags)
47
+ log(:debug, "Read value from key: '#{key}'")
48
48
  return value
49
49
  end
50
50
 
@@ -59,13 +59,13 @@ module AtomicCache
59
59
 
60
60
  # quick check back to see if the other process has finished
61
61
  # or fall back to the last known value
62
- value = quick_retry(keyspace, options, tags) || last_known_value(keyspace, options, tags)
62
+ value = quick_retry(key, options, tags) || last_known_value(keyspace, options, tags)
63
63
  return value if value.present?
64
64
 
65
65
  # wait for the other process if a last known value isn't there
66
66
  if key.present?
67
67
  return time('wait.run', tags: tags) do
68
- wait_for_new_value(key, options, tags)
68
+ wait_for_new_value(keyspace, options, tags)
69
69
  end
70
70
  end
71
71
 
@@ -109,10 +109,8 @@ module AtomicCache
109
109
  nil
110
110
  end
111
111
 
112
- def quick_retry(keyspace, options, tags)
113
- key = @timestamp_manager.current_key(keyspace)
112
+ def quick_retry(key, options, tags)
114
113
  duration = option(:quick_retry_ms, options, DEFAULT_quick_retry_ms)
115
-
116
114
  if duration.present? and key.present?
117
115
  sleep(duration.to_f / 1000)
118
116
  value = @storage.read(key, options)
@@ -136,6 +134,7 @@ module AtomicCache
136
134
  # last known key may have expired
137
135
  if !lkv.nil?
138
136
  metrics(:increment, 'last-known-value.present', tags: tags)
137
+ log(:debug, "Read value from last known value key: '#{lkk}'")
139
138
  return lkv
140
139
  end
141
140
 
@@ -149,7 +148,7 @@ module AtomicCache
149
148
  nil
150
149
  end
151
150
 
152
- def wait_for_new_value(key, options, tags)
151
+ def wait_for_new_value(keyspace, options, tags)
153
152
  max_retries = option(:max_retries, options, DEFAULT_MAX_RETRIES)
154
153
  max_retries.times do |attempt|
155
154
  metrics_tags = tags.clone.push("attempt:#{attempt}")
@@ -160,6 +159,8 @@ module AtomicCache
160
159
  backoff_duration_ms = option(:backoff_duration_ms, options, backoff_duration_ms)
161
160
  sleep((backoff_duration_ms.to_f / 1000) * attempt)
162
161
 
162
+ # re-fetch the key each time, to make sure we're actually getting the latest key with the correct LMT
163
+ key = @timestamp_manager.current_key(keyspace)
163
164
  value = @storage.read(key, options)
164
165
  if !value.nil?
165
166
  metrics(:increment, 'wait.present', tags: metrics_tags)
@@ -168,7 +169,7 @@ module AtomicCache
168
169
  end
169
170
 
170
171
  metrics(:increment, 'wait.give-up')
171
- log(:warn, "Giving up fetching cache key `#{key}`. Exceeded max retries (#{max_retries}).")
172
+ log(:warn, "Giving up waiting. Exceeded max retries (#{max_retries}).")
172
173
  nil
173
174
  end
174
175
 
@@ -26,7 +26,7 @@ module AtomicCache
26
26
  end
27
27
  end
28
28
 
29
- def cache_class(kls)
29
+ def force_cache_class(kls)
30
30
  ATOMIC_CACHE_CONCERN_MUTEX.synchronize do
31
31
  @atomic_cache_class = kls
32
32
  end
@@ -10,10 +10,6 @@ module AtomicCache
10
10
  class Dalli < Store
11
11
  extend Forwardable
12
12
 
13
- ADD_SUCCESS = 'STORED'
14
- ADD_UNSUCCESSFUL = 'NOT_STORED'
15
- ADD_EXISTS = 'EXISTS'
16
-
17
13
  def_delegators :@dalli_client, :delete
18
14
 
19
15
  def initialize(dalli_client)
@@ -27,16 +23,17 @@ module AtomicCache
27
23
  # dalli expects time in seconds
28
24
  # https://github.com/petergoldstein/dalli/blob/b8f4afe165fb3e07294c36fb1c63901b0ed9ce10/lib/dalli/client.rb#L27
29
25
  # TODO: verify this unit is being treated correctly through the system
30
- response = @dalli_client.add(key, new_value, ttl, opts)
31
- response.start_with?(ADD_SUCCESS)
26
+ !!@dalli_client.add(key, new_value, ttl, opts)
32
27
  end
33
28
 
34
29
  def read(key, user_options={})
35
- @dalli_client.read(key, user_options)
30
+ @dalli_client.get(key, user_options)
36
31
  end
37
32
 
38
33
  def set(key, value, user_options={})
39
- @dalli_client.set(key, value, user_options)
34
+ ttl = user_options[:ttl]
35
+ user_options.delete(:ttl)
36
+ !!@dalli_client.set(key, value, ttl, user_options)
40
37
  end
41
38
 
42
39
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicCache
4
- VERSION = "0.2.1.rc2"
4
+ VERSION = "0.3.0.rc1"
5
5
  end
@@ -104,12 +104,12 @@ describe 'AtomicCacheConcern' do
104
104
  class Foo2
105
105
  include AtomicCache::GlobalLMTCacheConcern
106
106
  cache_version(3)
107
- cache_class('foo')
107
+ force_cache_class('foo')
108
108
  end
109
109
  Foo2
110
110
  end
111
111
 
112
- it 'uses the given version and cache_class become part of the cache keyspace' do
112
+ it 'uses the given version and force_cache_class become part of the cache keyspace' do
113
113
  subject.expire_cache
114
114
  expect(key_storage.store).to have_key(:'foo:v3:lmt')
115
115
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'Integration' do
6
+ let(:key_storage) { AtomicCache::Storage::SharedMemory.new }
7
+ let(:cache_storage) { AtomicCache::Storage::SharedMemory.new }
8
+ let(:keyspace) { AtomicCache::Keyspace.new(namespace: 'int.waiting') }
9
+ let(:timestamp_manager) { AtomicCache::LastModTimeKeyManager.new(keyspace: keyspace, storage: key_storage) }
10
+
11
+ let(:generating_client) { AtomicCache::AtomicCacheClient.new(storage: cache_storage, timestamp_manager: timestamp_manager) }
12
+ let(:waiting_client) { AtomicCache::AtomicCacheClient.new(storage: cache_storage, timestamp_manager: timestamp_manager) }
13
+
14
+ it 'correctly waits for a key when no last know value is available' do
15
+ generating_thread = ClientThread.new(generating_client, keyspace)
16
+ generating_thread.start
17
+ waiting_thread = ClientThread.new(waiting_client, keyspace)
18
+ waiting_thread.start
19
+
20
+ generating_thread.generate
21
+ sleep 0.05
22
+ waiting_thread.fetch
23
+ sleep 0.05
24
+ generating_thread.complete
25
+ sleep 0.05
26
+
27
+ generating_thread.terminate
28
+ waiting_thread.terminate
29
+
30
+ expect(generating_thread.result).to eq([1, 2, 3])
31
+ expect(waiting_thread.result).to eq([1, 2, 3])
32
+ end
33
+ end
34
+
35
+
36
+ # Avert your eyes:
37
+ # this class allows atomic client interaction to happen asynchronously so that
38
+ # the waiting behavior of the client can be tested simultaneous to controlling how
39
+ # long the 'generate' behavior takes
40
+ #
41
+ # It works by accepting an incoming 'message' which it places onto one of two queues
42
+ class ClientThread
43
+ attr_reader :result
44
+
45
+ def initialize(client, keyspace)
46
+ @keyspace = keyspace
47
+ @client = client
48
+ @msg_queue = Queue.new
49
+ @generate_queue = Queue.new
50
+ @result = nil
51
+ end
52
+
53
+ def start
54
+ @thread = Thread.new(&method(:run))
55
+ end
56
+
57
+ def fetch
58
+ @msg_queue << :fetch
59
+ end
60
+
61
+ def generate
62
+ @msg_queue << :generate
63
+ end
64
+
65
+ def complete
66
+ @generate_queue << :complete
67
+ end
68
+
69
+ def terminate
70
+ @msg_queue << :terminate
71
+ end
72
+
73
+ private
74
+
75
+ def run
76
+ loop do
77
+ msg = @msg_queue.pop
78
+ sleep 0.001; next unless msg
79
+
80
+ case msg
81
+ when :terminate
82
+ Thread.stop
83
+ when :generate
84
+ do_generate
85
+ when :fetch
86
+ @result = @client.fetch(@keyspace)
87
+ end
88
+ end
89
+ end
90
+
91
+ def do_generate
92
+ @client.fetch(@keyspace) do
93
+ loop do
94
+ msg = @generate_queue.pop
95
+ sleep 0.001; next unless msg
96
+ break if msg == :complete
97
+ end
98
+ @result = [1, 2, 3] # generated value
99
+ @result
100
+ end
101
+ end
102
+ end
@@ -13,13 +13,20 @@ describe 'Dalli' do
13
13
  let(:dalli_client) { FakeDalli.new }
14
14
  subject { AtomicCache::Storage::Dalli.new(dalli_client) }
15
15
 
16
- it 'delegates #set without options' do
17
- expect(dalli_client).to receive(:set).with('key', 'value', {})
18
- subject.set('key', 'value')
16
+ context '#set' do
17
+ it 'delegates #set without options' do
18
+ expect(dalli_client).to receive(:set).with('key', 'value', nil, {})
19
+ subject.set('key', 'value')
20
+ end
21
+
22
+ it 'delegates #set with TTL' do
23
+ expect(dalli_client).to receive(:set).with('key', 'value', 500, {})
24
+ subject.set('key', 'value', { ttl: 500 })
25
+ end
19
26
  end
20
27
 
21
28
  it 'delegates #read without options' do
22
- expect(dalli_client).to receive(:read).with('key', {}).and_return('asdf')
29
+ expect(dalli_client).to receive(:get).with('key', {}).and_return('asdf')
23
30
  subject.read('key')
24
31
  end
25
32
 
@@ -30,7 +37,7 @@ describe 'Dalli' do
30
37
 
31
38
  context '#add' do
32
39
  before(:each) do
33
- allow(dalli_client).to receive(:add).and_return('NOT_STORED\r\n')
40
+ allow(dalli_client).to receive(:add).and_return(false)
34
41
  end
35
42
 
36
43
  it 'delegates to #add with the raw option set' do
@@ -40,22 +47,17 @@ describe 'Dalli' do
40
47
  end
41
48
 
42
49
  it 'returns true when the add is successful' do
43
- expect(dalli_client).to receive(:add).and_return('STORED\r\n')
50
+ expect(dalli_client).to receive(:add).and_return(12339031748204560384)
44
51
  result = subject.add('key', 'value', 100)
45
52
  expect(result).to eq(true)
46
53
  end
47
54
 
48
55
  it 'returns false if the key already exists' do
49
- expect(dalli_client).to receive(:add).and_return('EXISTS\r\n')
56
+ expect(dalli_client).to receive(:add).and_return(false)
50
57
  result = subject.add('key', 'value', 100)
51
58
  expect(result).to eq(false)
52
59
  end
53
60
 
54
- it 'returns false if the add fails' do
55
- expect(dalli_client).to receive(:add).and_return('NOT_STORED\r\n')
56
- result = subject.add('key', 'value', 100)
57
- expect(result).to eq(false)
58
- end
59
61
  end
60
62
 
61
63
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atomic_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1.rc2
4
+ version: 0.3.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ibotta Developers
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-06-22 00:00:00.000000000 Z
12
+ date: 2021-07-02 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: bundler
@@ -171,7 +171,8 @@ dependencies:
171
171
  - - "~>"
172
172
  - !ruby/object:Gem::Version
173
173
  version: '0.1'
174
- description: desc
174
+ description: A gem which prevents the thundering herd problem through a distributed
175
+ lock
175
176
  email: osscompliance@ibotta.com
176
177
  executables: []
177
178
  extensions: []
@@ -200,6 +201,7 @@ files:
200
201
  - spec/atomic_cache/atomic_cache_client_spec.rb
201
202
  - spec/atomic_cache/concerns/global_lmt_cache_concern_spec.rb
202
203
  - spec/atomic_cache/default_config_spec.rb
204
+ - spec/atomic_cache/integration/waiting_spec.rb
203
205
  - spec/atomic_cache/key/keyspace_spec.rb
204
206
  - spec/atomic_cache/key/last_mod_time_key_manager_spec.rb
205
207
  - spec/atomic_cache/storage/dalli_spec.rb
@@ -229,5 +231,9 @@ requirements: []
229
231
  rubygems_version: 3.0.8
230
232
  signing_key:
231
233
  specification_version: 4
232
- summary: summary
234
+ summary: In a nutshell:* The key of every cached value includes a timestamp* Once
235
+ a cache key is written to, it is never written over* When a newer version of a cached
236
+ value is available, it is written to a new key* When a new value is being generated
237
+ for a new key only 1 process is allowed to do so at a time* While the new value
238
+ is being generated, other processes read one key older than most recent
233
239
  test_files: []