response_bank 1.1.0 → 1.2.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
  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