cachext 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 1141e77be7834868ab37636024f4eeea8c719581
4
- data.tar.gz: aed591612989be4abf58211a409542c9f51c189e
3
+ metadata.gz: 99eccddb9064b17d7043869d9f62d2a39438cd12
4
+ data.tar.gz: 9cd79f5b34f472c9c855b981cab782e4af3f6086
5
5
  SHA512:
6
- metadata.gz: 4f0bcdf511a0c61b65ee32a4b7a82c1f14864c08bd9a7c5c609970f6bc8f13cbe3c4c2e183966fb75d16cbeb2ce5a7f5f490c1ab9f02f268df8c1f324248370a
7
- data.tar.gz: 7bffc2e75ce62b71a0a3db99c917653304291bb7ad854582a04caf7103bbfc18deb2a9c2e9026959ca51bccf143b9d4d532aa886d5afa312531cdd8ae65a771d
6
+ metadata.gz: db58512dcee683c1b3a3ea996eb0505f57020e1b81ae8c73e933f10fc9956670fae3cd7d09293063eeeca043305c6a31c800ab4861078265afcb8477866985b7
7
+ data.tar.gz: 31204397cfda1149f6d77e08a75392359c244a2399a1a7bb8ea0468998b27ebac4e6a13eae8a988621534d95a534596abf29c3a99d4af89377aa20b91b2f7408
data/README.md CHANGED
@@ -2,24 +2,142 @@
2
2
 
3
3
  Extensions to normal Rails caching:
4
4
 
5
- * Lock
5
+ * Lock (inspired by https://github.com/seamusabshere/lock_and_cache)
6
6
  * Backup
7
7
 
8
- ## Installation
8
+ ## Quickstart
9
9
 
10
- Add this line to your application's Gemfile:
10
+ ```ruby
11
+ Cachext.config.cache = Rails.cache
12
+ Cachext.config.redis = Redis.current
13
+
14
+ key = [:foo, :bar, 1]
15
+ Cachext.fetch key, expires_in: 2.hours, default: "cow" do
16
+ Faraday.get "http://example.com/foo/bar/1"
17
+ end
18
+ ```
19
+
20
+ * Other services making the same call at the same time will wait for the
21
+ first to complete, so only 1 call is made in a 2 hour window
22
+ * A backup of the value is stored too, so if the service raises a
23
+ Faraday::Error::ConnectionFailed we'll return the backup
24
+ * If no backup exists but we got a ConnectionFailed, we'll return the default
25
+ of "cow"
11
26
 
12
27
  ```ruby
13
- gem 'cachext'
28
+ Record = Struct.new :id
29
+ Cachext.multi [:foo, :bar], [1,2,3], expires_in: 5.minutes do |ids|
30
+ data = JSON.parse Faraday.get("http://example.com/foo/bar?ids=#{ids.join(',')}")
31
+ data.each_with_object({}) do |record, acc|
32
+ acc[record["id"]] = Record.new record["id"]
33
+ end
34
+ end
35
+ # => { 1 => Record.new(1), 2 => Record.new(2), 3 => Record.new(3) }
14
36
  ```
15
37
 
16
- And then execute:
38
+ * The passed block will be called with the ids that were not available in the
39
+ cache. The return value of the block should either be a hash with keys of
40
+ ids, or an array of objects that have `id` methods.
41
+ * In the event of a server error (ie `ConnectionFailed`), backup values are
42
+ used.
17
43
 
18
- $ bundle
44
+ ## Configuration options
45
+
46
+ ```ruby
47
+ Cachext.config.cache = Rails.cache
48
+ ```
49
+
50
+ `Cachext` expects a cache store that has the `ActiveSupport::Cache` interface,
51
+ so that can be Memcache, Redis, FileStore, etc.
52
+
53
+ ```ruby
54
+ Cachext.config.redis = Redis.current
55
+ ```
56
+
57
+ `Cachext` uses redis for locking (the
58
+ [Redlock](https://github.com/leandromoreira/redlock-rb) gem under the hood), so
59
+ we need at least Redis 2.8.
60
+
61
+ ```ruby
62
+ Cachext.config.raise_errors = false
63
+ Cachext.config.default_errors = [
64
+ Faraday::Error::ConnectionFailed,
65
+ Faraday::Error::TimeoutError,
66
+ ]
67
+ ```
68
+
69
+ By default `Cachext` will not re-raise the standard default errors. Setting
70
+ this to `true` is helpful in a test environment. The `default_errors` are those
71
+ caught as transient issues that a backup will be used for.
72
+
73
+ ```ruby
74
+ Cachext.config.not_found_errors = [Faraday::Error::ResourceNotFound]
75
+ ```
76
+
77
+ If a NotFound exception is raised, the backup is *not* used, and any backup
78
+ that exists will be deleted. Then the exception will be re-raised.
79
+
80
+ ```ruby
81
+ Cachext.config.default_expires_in = 60 # in seconds
82
+ ```
83
+
84
+ The default TTL for values fetched. Only used for the "fresh" cache, not the
85
+ backup (which has no TTL).
86
+
87
+ ```ruby
88
+ Cachext.config.max_lock_wait = 5 # in seconds
89
+ ```
90
+
91
+ The most we'll wait for a lock to unlock. If it takes more than this value to
92
+ get a lock (due to another service holding the lock while making the call),
93
+ we'll fallback to the backup value.
94
+
95
+ ```ruby
96
+ Cachext.config.debug = ENV['CACHEXT_DEBUG'] == "true"
97
+ ```
98
+
99
+ If `debug` is set to `true` (or you run your program/test with
100
+ `CACHEXT_DEBUG=true`), you'll get lots of debug messages around the locking and
101
+ whats going on. Very helpful for debugging :)
102
+
103
+ ```ruby
104
+ Cachext.config.heartbeat_expires = 2 # in seconds
105
+ ```
106
+
107
+ If a process that holds a lock crashes, other processes will have to wait this
108
+ many seconds for the lock to expire.
109
+
110
+ ```ruby
111
+ Cachext.config.error_logger = nil
112
+ ```
113
+
114
+ If set to an object that responds to call, will `call` with any errors caught.
19
115
 
20
116
  ## Usage
21
117
 
22
- Replace `Rails.cache.fetch` with `Cachext.fetch`.
118
+ ```ruby
119
+ Cachext.fetch key, options, &block
120
+ ```
121
+
122
+ Available options:
123
+
124
+ * `expires_in`: override for the `default_expires_in`, in seconds
125
+ * `default`: object or proc that will be used as the default if no backup is found
126
+ * `errors`: override for the `default_errors` to be caught
127
+ * `reraise_errors`: default `true`, if set to `false` NotFound errors will not
128
+ be raised
129
+ * `not_found_error`: override for `not_found_errors`
130
+ * `heartbeat_expires`: override for `heartbeat_expires`
131
+
132
+ ```ruby
133
+ Cachext.multi key_base, ids, options, &block
134
+ ```
135
+
136
+ Available options:
137
+
138
+ * `expires_in`: override for `default_expires_in`, in seconds
139
+ * `return_array`: return an array instead of a hash. Will include missing
140
+ records as `Cachext::MissingRecord` objects so you can deal with them.
23
141
 
24
142
  ## Development
25
143
 
@@ -40,7 +40,7 @@ module Cachext
40
40
  end
41
41
 
42
42
  def handle_error key, options, error
43
- @config.error_logger.error error
43
+ @config.error_logger.call error if @config.log_errors?
44
44
  raise if @config.raise_errors && reraise_errors
45
45
  end
46
46
 
@@ -36,5 +36,9 @@ module Cachext
36
36
  def lock_redis
37
37
  @lock_redis ||= Redis::Namespace.new :cachext, redis: redis
38
38
  end
39
+
40
+ def log_errors?
41
+ error_logger.present?
42
+ end
39
43
  end
40
44
  end
@@ -4,7 +4,7 @@ require "active_support/core_ext/hash/reverse_merge"
4
4
 
5
5
  module Cachext
6
6
  class Multi
7
- delegate :cache, :default_errors, :error_logger, to: :@config
7
+ attr_reader :config, :key_base
8
8
 
9
9
  def initialize config, key_base, options = {}
10
10
  @config = config
@@ -30,6 +30,10 @@ module Cachext
30
30
  @options.fetch :expires_in, @config.default_expires_in
31
31
  end
32
32
 
33
+ def heartbeat_expires
34
+ @options.fetch :heartbeat_expires, config.heartbeat_expires
35
+ end
36
+
33
37
  private
34
38
 
35
39
  def missing_records ids
@@ -39,9 +43,10 @@ module Cachext
39
43
  class FindByIds
40
44
  attr_reader :multi, :ids
41
45
 
42
- delegate :cache, to: :multi
46
+ delegate :config, :heartbeat_expires, to: :multi
47
+ delegate :cache, :lock_manager, :max_lock_wait, to: :config
43
48
 
44
- def initialize(multi, ids, lookup)
49
+ def initialize multi, ids, lookup
45
50
  @multi = multi
46
51
  @ids = ids
47
52
  @lookup = lookup
@@ -64,12 +69,44 @@ module Cachext
64
69
 
65
70
  def direct
66
71
  @direct ||= if uncached_or_stale_ids.length > 0
67
- records = uncached_where uncached_or_stale_ids
68
- write_cache records
69
- records
72
+ with_lock uncached_or_stale_ids do
73
+ records = uncached_where uncached_or_stale_ids
74
+ write_cache records
75
+ records
76
+ end
70
77
  else
71
78
  {}
72
79
  end
80
+ rescue Features::Lock::TimeoutWaitingForLock => e
81
+ config.error_logger.call e if config.log_errors?
82
+ {}
83
+ end
84
+
85
+ def with_lock ids, &block
86
+ @lock_info = obtain_lock ids
87
+ block.call
88
+ ensure
89
+ lock_manager.unlock @lock_info if @lock_info
90
+ end
91
+
92
+ def obtain_lock ids
93
+ lock_key = lock_key_from_ids ids
94
+
95
+ start_time = Time.now
96
+
97
+ until lock_info = lock_manager.lock(lock_key, (heartbeat_expires * 1000).ceil)
98
+ sleep rand
99
+ if Time.now - start_time > max_lock_wait
100
+ raise Features::Lock::TimeoutWaitingForLock
101
+ end
102
+ end
103
+
104
+ lock_info
105
+ end
106
+
107
+ def lock_key_from_ids(ids)
108
+ key = Key.new multi.key_base + ids
109
+ key.digest
73
110
  end
74
111
 
75
112
  def write_cache records
@@ -95,20 +132,41 @@ module Cachext
95
132
  end
96
133
 
97
134
  def uncached_where ids
98
- records = @lookup.call ids
135
+ with_heartbeat_extender lock_key_from_ids(ids) do
136
+ records = @lookup.call ids
99
137
 
100
- if records.is_a?(Array)
101
- records = records.each_with_object({}) do |record, acc|
102
- acc[record.id] = record
138
+ if records.is_a?(Array)
139
+ records = records.each_with_object({}) do |record, acc|
140
+ acc[record.id] = record
141
+ end
103
142
  end
104
- end
105
143
 
106
- delete_backups ids - records.keys
107
- records
108
- rescue *multi.default_errors => e
109
- multi.error_logger.error e
144
+ delete_backups ids - records.keys
145
+ records
146
+ end
147
+ rescue *config.default_errors => e
148
+ config.error_logger.call e if config.log_errors?
110
149
  {}
111
150
  end
151
+
152
+ def with_heartbeat_extender lock_key, &block
153
+ done = false
154
+ heartbeat_frequency = heartbeat_expires / 2
155
+
156
+ Thread.new do
157
+ loop do
158
+ break if done
159
+ sleep heartbeat_frequency
160
+ break if done
161
+ lock_manager.lock lock_key, (heartbeat_expires * 1000).ceil, extend: @lock_info
162
+ end
163
+ end
164
+
165
+ block.call
166
+ ensure
167
+ lock_manager.unlock @lock_info
168
+ done = true
169
+ end
112
170
  end
113
171
  end
114
172
  end
@@ -8,12 +8,14 @@ module Cachext
8
8
  :not_found_error,
9
9
  :heartbeat_expires
10
10
 
11
- def initialize config, expires_in: config.default_expires_in,
11
+ def initialize config,
12
+ expires_in: config.default_expires_in,
12
13
  default: nil,
13
14
  errors: config.default_errors,
14
15
  reraise_errors: true,
15
16
  not_found_error: config.not_found_errors,
16
17
  heartbeat_expires: config.heartbeat_expires
18
+
17
19
  @expires_in = expires_in
18
20
  @default = default
19
21
  @errors = errors
@@ -1,3 +1,3 @@
1
1
  module Cachext
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cachext
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Donald Plummer