response_bank 1.1.0 → 1.2.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
  SHA256:
3
- metadata.gz: 2a5d65c4fd4310708e807cde576efeebdc590c16eb8e16190aef77a715ed48c2
4
- data.tar.gz: 498da7cd7cfcc2da09b517e6694093551e79288ec4e52e815950448353ba4a0f
3
+ metadata.gz: 75476fbf8271b8744fdca486f31ddfff585ab0680e29c600e25393d24b6b6fbb
4
+ data.tar.gz: 6092909983069ec5689e4e6ada0d241e632331fd6c97fb94e4ce5029c4cf228a
5
5
  SHA512:
6
- metadata.gz: 5985c959c55ebf8917d9687dd0e3c3d82cae36684f7466d7c1d1805097da2fd57cadaa6efa9f7d618a757e24949a7e493aefe0e298f5c0254d9b595f7871bba8
7
- data.tar.gz: 5bc8ac60f2d2ce149120a8f0bc98aca57899511359f5a67a125b6cc3894edf0043dffad8be635ae9fa95a9d1b295220a091b0d2faa118982f9740eac5e3d6786
6
+ metadata.gz: ccade0feb4bcc259e6bd0d3435799960014f204031b564ca88892e5c80afce36997f266b91acb0eb2af1e64f80488af49166fe4a99ab2d31874cac9827e37eaa
7
+ data.tar.gz: 5e74d777bfd30f8eed957d5ed07d11ffab8e6d4d462194277b7a38c28b70728445d0c598a26173020fc1d26c3c11410aabc6cf7f79f5a06cd806546a687cff7e
data/README.md CHANGED
@@ -1,126 +1,128 @@
1
- # ResponseBank [![Build Status](https://secure.travis-ci.org/Shopify/response_bank.png)](http://travis-ci.org/Shopify/response_bank)
1
+ # ResponseBank [![Build Status](https://secure.travis-ci.org/Shopify/response_bank.png)](http://travis-ci.org/Shopify/response_bank) [![CI Status](https://github.com/Shopify/response_bank/actions/workflows/ci.yml/badge.svg)](https://github.com/Shopify/response_bank/actions/workflows/ci.yml)
2
2
 
3
- ### Features
3
+ ## Features
4
4
 
5
5
  * Serve gzip'd content
6
6
  * Add ETag and 304 Not Modified headers
7
7
  * Generational caching
8
8
  * No explicit expiry
9
9
 
10
- ### Support
10
+ ## Support
11
11
 
12
12
  This gem supports the following versions of Ruby and Rails:
13
13
 
14
- * Ruby 2.4.0+
15
- * Rails 5.0.0+
14
+ * Ruby 2.7.0+
15
+ * Rails 6.0.0+
16
16
 
17
- ### Usage
17
+ ## Usage
18
18
 
19
19
  1. include the gem in your Gemfile
20
20
 
21
- ```ruby
22
- gem 'response_bank'
23
- ```
21
+ ```ruby
22
+ gem 'response_bank'
23
+ ```
24
24
 
25
25
  2. add an initializer file. We need to configure the `acquire_lock` method, set the cache store and the logger
26
26
 
27
- ```ruby
28
- require 'response_bank'
27
+ ```ruby
28
+ require 'response_bank'
29
29
 
30
- module ResponseBank
31
- LOCK_TTL = 90
30
+ module ResponseBank
31
+ LOCK_TTL = 90
32
32
 
33
- class << self
34
- def acquire_lock(cache_key)
35
- cache_store.write("#{cache_key}:lock", '1', unless_exist: true, expires_in: LOCK_TTL, raw: true)
33
+ class << self
34
+ def acquire_lock(cache_key)
35
+ cache_store.write("#{cache_key}:lock", '1', unless_exist: true, expires_in: LOCK_TTL, raw: true)
36
+ end
37
+ end
36
38
  end
37
- end
38
- end
39
39
 
40
- ResponseBank.cache_store = ActiveSupport::Cache.lookup_store(Rails.configuration.cache_store)
41
- ResponseBank.logger = Rails.logger
40
+ ResponseBank.cache_store = ActiveSupport::Cache.lookup_store(Rails.configuration.cache_store)
41
+ ResponseBank.logger = Rails.logger
42
42
 
43
- ```
43
+ ```
44
44
 
45
45
  3. enables caching on your application
46
- ```ruby
47
- config.action_controller.perform_caching = true
48
- ```
46
+
47
+ ```ruby
48
+ config.action_controller.perform_caching = true
49
+ ```
49
50
 
50
51
  4. use `#response_cache` method to any desired controller's action
51
52
 
52
- ```ruby
53
- class PostsController < ApplicationController
54
- def show
55
- response_cache do
56
- @post = @shop.posts.find(params[:id])
57
- respond_with(@post)
53
+ ```ruby
54
+ class PostsController < ApplicationController
55
+ def show
56
+ response_cache do
57
+ @post = @shop.posts.find(params[:id])
58
+ respond_with(@post)
59
+ end
60
+ end
58
61
  end
59
- end
60
- end
61
- ```
62
+ ```
62
63
 
63
64
  5. **(optional)** set a custom TTL for the cache by overriding the `write_to_backing_cache_store` method in your initializer file
64
- ```ruby
65
- module ResponseBank
66
- CACHE_TTL = 30.minutes
67
- def write_to_backing_cache_store(_env, key, payload, expires_in: CACHE_TTL)
68
- cache_store.write(key, payload, raw: true, expires_in: expires_in)
69
- end
70
- end
71
- ```
72
65
 
73
- 6. **(optional)** override custom cache key data. For default, cache key is defined by URL and query string
66
+ ```ruby
67
+ module ResponseBank
68
+ CACHE_TTL = 30.minutes
69
+ def write_to_backing_cache_store(_env, key, payload, expires_in: nil)
70
+ cache_store.write(key, payload, raw: true, expires_in: expires_in || CACHE_TTL)
71
+ end
72
+ end
73
+ ```
74
74
 
75
- ```ruby
76
- class PostsController < ApplicationController
77
- before_action :set_shop
75
+ 6. **(optional)** override custom cache key data. For default, cache key is defined by URL and query string
78
76
 
79
- def index
80
- response_cache do
81
- @post = @shop.posts
82
- respond_with(@post)
77
+ ```ruby
78
+ class PostsController < ApplicationController
79
+ before_action :set_shop
80
+
81
+ def index
82
+ response_cache do
83
+ @post = @shop.posts
84
+ respond_with(@post)
85
+ end
86
+ end
87
+
88
+ def show
89
+ response_cache do
90
+ @post = @shop.posts.find(params[:id])
91
+ respond_with(@post)
92
+ end
93
+ end
94
+
95
+ def another_action
96
+ # custom cache key data
97
+ cache_key = {
98
+ action: action_name,
99
+ format: request.format,
100
+ shop_updated_at: @shop.updated_at
101
+ # you may add more keys here
102
+ }
103
+ response_cache cache_key do
104
+ @post = @shop.posts.find(params[:id])
105
+ respond_with(@post)
106
+ end
107
+ end
108
+
109
+ # override default cache key data globally per class
110
+ def cache_key_data
111
+ {
112
+ action: action_name,
113
+ format: request.format,
114
+ params: params.slice(:id),
115
+ shop_version: @shop.version
116
+ # you may add more keys here
117
+ }
118
+ end
119
+
120
+ def set_shop
121
+ # @shop = ...
122
+ end
83
123
  end
84
- end
124
+ ```
85
125
 
86
- def show
87
- response_cache do
88
- @post = @shop.posts.find(params[:id])
89
- respond_with(@post)
90
- end
91
- end
92
-
93
- def another_action
94
- # custom cache key data
95
- cache_key = {
96
- action: action_name,
97
- format: request.format,
98
- shop_updated_at: @shop.updated_at
99
- # you may add more keys here
100
- }
101
- response_cache cache_key do
102
- @post = @shop.posts.find(params[:id])
103
- respond_with(@post)
104
- end
105
- end
106
-
107
- # override default cache key data globally per class
108
- def cache_key_data
109
- {
110
- action: action_name,
111
- format: request.format,
112
- params: params.slice(:id),
113
- shop_version: @shop.version
114
- # you may add more keys here
115
- }
116
- end
117
-
118
- def set_shop
119
- # @shop = ...
120
- end
121
- end
122
- ```
123
-
124
- ### License
126
+ ## License
125
127
 
126
128
  ResponseBank is released under the [MIT License](LICENSE.txt).
@@ -3,6 +3,10 @@ require 'useragent'
3
3
 
4
4
  module ResponseBank
5
5
  class Middleware
6
+ # Limit the cached headers
7
+ # TODO: Make this lowercase/case-insentitive as per rfc2616 §4.2
8
+ CACHEABLE_HEADERS = ["Location", "Content-Type", "ETag", "Content-Encoding", "Last-Modified", "Cache-Control", "Expires", "Surrogate-Keys", "Cache-Tags"].freeze
9
+
6
10
  REQUESTED_WITH = "HTTP_X_REQUESTED_WITH"
7
11
  ACCEPT = "HTTP_ACCEPT"
8
12
  USER_AGENT = "HTTP_USER_AGENT"
@@ -20,7 +24,6 @@ module ResponseBank
20
24
  if env['cacheable.cache']
21
25
  if [200, 404, 301, 304].include?(status)
22
26
  headers['ETag'] = env['cacheable.key']
23
- headers['X-Alternate-Cache-Key'] = env['cacheable.unversioned-key']
24
27
 
25
28
  if ie_ajax_request?(env)
26
29
  headers["Expires"] = "-1"
@@ -38,22 +41,18 @@ module ResponseBank
38
41
 
39
42
  body_gz = ResponseBank.compress(body_string)
40
43
 
44
+ cached_headers = headers.slice(*CACHEABLE_HEADERS)
41
45
  # Store result
42
- cache_data = [status, headers['Content-Type'], body_gz, timestamp]
43
- cache_data << headers['Location'] if status == 301
46
+ cache_data = [status, cached_headers, body_gz, timestamp]
44
47
 
45
48
  ResponseBank.write_to_cache(env['cacheable.key']) do
46
49
  payload = MessagePack.dump(cache_data)
47
50
  ResponseBank.write_to_backing_cache_store(
48
51
  env,
49
- env['cacheable.key'],
52
+ env['cacheable.unversioned-key'],
50
53
  payload,
51
54
  expires_in: env['cacheable.versioned-cache-expiry'],
52
55
  )
53
-
54
- if env['cacheable.unversioned-key']
55
- ResponseBank.write_to_backing_cache_store(env, env['cacheable.unversioned-key'], payload)
56
- end
57
56
  end
58
57
 
59
58
  # since we had to generate the gz version above already we may
@@ -3,6 +3,8 @@ require 'digest/md5'
3
3
 
4
4
  module ResponseBank
5
5
  class ResponseCacheHandler
6
+ CACHE_KEY_SCHEMA_VERSION = 1
7
+
6
8
  def initialize(
7
9
  key_data:,
8
10
  version_data:,
@@ -25,12 +27,13 @@ module ResponseBank
25
27
  @force_refill_cache = force_refill_cache
26
28
  @cache_store = cache_store
27
29
  @headers = headers || {}
30
+ @key_schema_version = @env.key?('cacheable.key_version') ? @env.key['cacheable.key_version'] : CACHE_KEY_SCHEMA_VERSION
28
31
  end
29
32
 
30
33
  def run!
31
34
  @env['cacheable.cache'] = true
32
- @env['cacheable.key'] = versioned_key_hash
33
- @env['cacheable.unversioned-key'] = unversioned_key_hash
35
+ @env['cacheable.key'] = entity_tag_hash
36
+ @env['cacheable.unversioned-key'] = cache_key_hash
34
37
 
35
38
  ResponseBank.log(cacheable_info_dump)
36
39
 
@@ -41,32 +44,32 @@ module ResponseBank
41
44
  end
42
45
  end
43
46
 
44
- def versioned_key_hash
45
- @versioned_key_hash ||= key_hash(versioned_key)
47
+ def entity_tag_hash
48
+ @entity_tag_hash ||= hash(entity_tag)
46
49
  end
47
50
 
48
- def unversioned_key_hash
49
- @unversioned_key_hash ||= key_hash(unversioned_key)
51
+ def cache_key_hash
52
+ @cache_key_hash ||= hash(cache_key)
50
53
  end
51
54
 
52
55
  private
53
56
 
54
- def key_hash(key)
57
+ def hash(key)
55
58
  "cacheable:#{Digest::MD5.hexdigest(key)}"
56
59
  end
57
60
 
58
- def versioned_key
59
- @versioned_key ||= ResponseBank.cache_key_for(key: @key_data, version: @version_data)
61
+ def entity_tag
62
+ @entity_tag ||= ResponseBank.cache_key_for(key: @key_data, version: @version_data, key_schema_version: @key_schema_version)
60
63
  end
61
64
 
62
- def unversioned_key
63
- @unversioned_key ||= ResponseBank.cache_key_for(key: @key_data)
65
+ def cache_key
66
+ @cache_key ||= ResponseBank.cache_key_for(key: @key_data, key_schema_version: @key_schema_version)
64
67
  end
65
68
 
66
69
  def cacheable_info_dump
67
70
  log_info = [
68
- "Raw cacheable.key: #{versioned_key}",
69
- "cacheable.key: #{versioned_key_hash}",
71
+ "Raw cacheable.key: #{entity_tag}",
72
+ "cacheable.key: #{entity_tag_hash}",
70
73
  ]
71
74
 
72
75
  if @env['HTTP_IF_NONE_MATCH']
@@ -78,42 +81,19 @@ module ResponseBank
78
81
 
79
82
  def try_to_serve_from_cache
80
83
  # Etag
81
- response = serve_from_browser_cache(versioned_key_hash)
82
-
84
+ response = serve_from_browser_cache(entity_tag_hash, @env['HTTP_IF_NONE_MATCH'])
83
85
  return response if response
84
86
 
85
- # Memcached
86
- response = if @serve_unversioned
87
- serve_from_cache(unversioned_key_hash, "Cache hit: server (unversioned)")
88
- else
89
- serve_from_cache(versioned_key_hash, "Cache hit: server")
90
- end
91
-
87
+ response = serve_from_cache(cache_key_hash, entity_tag_hash, @cache_age_tolerance)
92
88
  return response if response
93
89
 
94
- @env['cacheable.locked'] ||= false
95
-
96
- if @env['cacheable.locked'] || ResponseBank.acquire_lock(versioned_key_hash)
97
- # execute if we can get the lock
98
- @env['cacheable.locked'] = true
99
- elsif serving_from_noncurrent_but_recent_version_acceptable?
100
- # serve a stale version
101
- response = serve_from_cache(unversioned_key_hash, "Cache hit: server (recent)", @cache_age_tolerance)
102
-
103
- return response if response
104
- end
105
-
106
90
  # No cache hit; this request cannot be handled from cache.
107
91
  # Yield to the controller and mark for writing into cache.
108
92
  refill_cache
109
93
  end
110
94
 
111
- def serving_from_noncurrent_but_recent_version_acceptable?
112
- @cache_age_tolerance > 0
113
- end
114
-
115
- def serve_from_browser_cache(cache_key_hash)
116
- if @env["HTTP_IF_NONE_MATCH"] == cache_key_hash
95
+ def serve_from_browser_cache(entity_tag, if_none_match)
96
+ if etag_matches?(entity_tag, if_none_match)
117
97
  @env['cacheable.miss'] = false
118
98
  @env['cacheable.store'] = 'client'
119
99
 
@@ -126,7 +106,7 @@ module ResponseBank
126
106
  end
127
107
  end
128
108
 
129
- def serve_from_cache(cache_key_hash, message, cache_age_tolerance = nil)
109
+ def serve_from_cache(cache_key_hash, match_entity_tag = "*", cache_age_tolerance = nil)
130
110
  raw = ResponseBank.read_from_backing_cache_store(@env, cache_key_hash, backing_cache_store: @cache_store)
131
111
 
132
112
  if raw
@@ -135,37 +115,73 @@ module ResponseBank
135
115
  @env['cacheable.miss'] = false
136
116
  @env['cacheable.store'] = 'server'
137
117
 
138
- status, content_type, body, timestamp, location = hit
118
+ status, headers, body, timestamp = hit
139
119
 
140
- if cache_age_tolerance && page_too_old?(timestamp, cache_age_tolerance)
141
- ResponseBank.log("Found an unversioned cache entry, but it was too old (#{timestamp})")
120
+ @env['cacheable.locked'] ||= false
142
121
 
143
- nil
122
+ # to preserve the unversioned/versioned logging messages from past releases we split the match_entity_tag test
123
+ if match_entity_tag == "*"
124
+ ResponseBank.log("Cache hit: server (unversioned)")
125
+ # page tolerance only applies for versioned + etag mismatch
126
+ elsif etag_matches?(headers['ETag'], match_entity_tag)
127
+ ResponseBank.log("Cache hit: server")
144
128
  else
145
- @headers['Content-Type'] = content_type
146
-
147
- @headers['Location'] = location if location
148
-
149
- if @env["gzip"]
150
- @headers['Content-Encoding'] = "gzip"
129
+ # cache miss; check to see if any parallel requests already are regenerating the cache
130
+ if ResponseBank.acquire_lock(match_entity_tag)
131
+ # execute if we can get the lock
132
+ @env['cacheable.locked'] = true
133
+ return
134
+ elsif stale_while_revalidate?(timestamp, cache_age_tolerance)
135
+ # cache is being regenerated, can we avoid piling on and use a stale version in the interim?
136
+ ResponseBank.log("Cache hit: server (recent)")
151
137
  else
152
- # we have to uncompress because the client doesn't support gzip
153
- ResponseBank.log("uncompressing for client without gzip")
154
- body = ResponseBank.decompress(body)
138
+ ResponseBank.log("Found an unversioned cache entry, but it was too old (#{timestamp})")
139
+ return
155
140
  end
141
+ end
156
142
 
157
- ResponseBank.log(message)
143
+ # version check
144
+ # unversioned but tolerance threshold
145
+ # regen
146
+ @headers = @headers.merge(headers)
158
147
 
159
- [status, @headers, [body]]
148
+ if @env["gzip"]
149
+ @headers['Content-Encoding'] = "gzip"
150
+ else
151
+ # we have to uncompress because the client doesn't support gzip
152
+ ResponseBank.log("uncompressing for client without gzip")
153
+ body = ResponseBank.decompress(body)
160
154
  end
155
+ [status, @headers, [body]]
161
156
  end
162
157
  end
163
158
 
164
- def page_too_old?(timestamp, cache_age_tolerance)
165
- !timestamp || timestamp < (Time.now.to_i - cache_age_tolerance)
159
+ def etag_matches?(entity_tag, if_none_match)
160
+ # Support for Etag variations including:
161
+ # If-None-Match: abc
162
+ # If-None-Match: "abc"
163
+ # If-None-Match: W/"abc"
164
+ # If-None-Match: "abc", "def"
165
+ # If-None-Match: "*"
166
+ return false unless entity_tag
167
+ return false unless if_none_match
168
+
169
+ # strictly speaking an unquoted etag is not valid, yet common
170
+ # to avoid unintended greedy matches in we check for naked entity then includes with quoted entity values
171
+ if_none_match == "*" || if_none_match == entity_tag || if_none_match.include?(%{"#{entity_tag}"})
172
+ end
173
+
174
+ def stale_while_revalidate?(timestamp, cache_age_tolerance)
175
+ return false if !cache_age_tolerance
176
+ return false if !timestamp
177
+
178
+ timestamp >= (Time.now.to_i - cache_age_tolerance)
166
179
  end
167
180
 
168
181
  def refill_cache
182
+ # non cache hits do not yet have the lock
183
+ ResponseBank.acquire_lock(entity_tag_hash) unless @env['cacheable.locked']
184
+ @env['cacheable.locked'] = true
169
185
  @env['cacheable.miss'] = true
170
186
 
171
187
  ResponseBank.log("Refilling cache")
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module ResponseBank
3
- VERSION = "1.1.0"
3
+ VERSION = "1.2.0"
4
4
  end
data/lib/response_bank.rb CHANGED
@@ -49,17 +49,17 @@ module ResponseBank
49
49
 
50
50
  key = hash_value_str(data[:key])
51
51
 
52
- return key unless data.key?(:version)
52
+ key = %{#{data[:key_schema_version]}:#{key}} if data[:key_schema_version]
53
53
 
54
- version = hash_value_str(data[:version])
54
+ key = %{#{key}:#{hash_value_str(data[:version])}} if data[:version]
55
55
 
56
- [key, version].join(":")
56
+ key
57
57
  when Array
58
58
  data.inspect
59
59
  when Time, DateTime
60
60
  data.to_i
61
61
  when Date
62
- data.to_time.to_i
62
+ data.to_s # Date#to_i does not support timezones, using iso8601 instead
63
63
  when true, false, Integer, Symbol, String
64
64
  data.inspect
65
65
  else
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: response_bank
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tobias Lütke
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-08-04 00:00:00.000000000 Z
12
+ date: 2023-03-28 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: useragent
@@ -45,28 +45,28 @@ dependencies:
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: 5.13.0
48
+ version: 5.18.0
49
49
  type: :development
50
50
  prerelease: false
51
51
  version_requirements: !ruby/object:Gem::Requirement
52
52
  requirements:
53
53
  - - ">="
54
54
  - !ruby/object:Gem::Version
55
- version: 5.13.0
55
+ version: 5.18.0
56
56
  - !ruby/object:Gem::Dependency
57
57
  name: mocha
58
58
  requirement: !ruby/object:Gem::Requirement
59
59
  requirements:
60
60
  - - ">="
61
61
  - !ruby/object:Gem::Version
62
- version: 1.10.0
62
+ version: 2.0.0
63
63
  type: :development
64
64
  prerelease: false
65
65
  version_requirements: !ruby/object:Gem::Requirement
66
66
  requirements:
67
67
  - - ">="
68
68
  - !ruby/object:Gem::Version
69
- version: 1.10.0
69
+ version: 2.0.0
70
70
  - !ruby/object:Gem::Dependency
71
71
  name: rake
72
72
  requirement: !ruby/object:Gem::Requirement
@@ -87,28 +87,14 @@ dependencies:
87
87
  requirements:
88
88
  - - ">="
89
89
  - !ruby/object:Gem::Version
90
- version: '5.0'
90
+ version: '6.1'
91
91
  type: :development
92
92
  prerelease: false
93
93
  version_requirements: !ruby/object:Gem::Requirement
94
94
  requirements:
95
95
  - - ">="
96
96
  - !ruby/object:Gem::Version
97
- version: '5.0'
98
- - !ruby/object:Gem::Dependency
99
- name: tzinfo-data
100
- requirement: !ruby/object:Gem::Requirement
101
- requirements:
102
- - - ">="
103
- - !ruby/object:Gem::Version
104
- version: 1.2019.3
105
- type: :development
106
- prerelease: false
107
- version_requirements: !ruby/object:Gem::Requirement
108
- requirements:
109
- - - ">="
110
- - !ruby/object:Gem::Version
111
- version: 1.2019.3
97
+ version: '6.1'
112
98
  description:
113
99
  email:
114
100
  - tobi@shopify.com
@@ -139,14 +125,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
139
125
  requirements:
140
126
  - - ">="
141
127
  - !ruby/object:Gem::Version
142
- version: 2.4.0
128
+ version: 2.7.0
143
129
  required_rubygems_version: !ruby/object:Gem::Requirement
144
130
  requirements:
145
131
  - - ">="
146
132
  - !ruby/object:Gem::Version
147
133
  version: '0'
148
134
  requirements: []
149
- rubygems_version: 3.2.20
135
+ rubygems_version: 3.4.9
150
136
  signing_key:
151
137
  specification_version: 4
152
138
  summary: Simple response caching for Ruby applications