response_bank 1.0.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: 22412918297172ba47f51811bb250ace6dbb2b3d8eb26405f0f970a18b2479de
4
- data.tar.gz: 94022c0b2e796e9a0a8a0fcc47f7f4ba40f64118458695554683d21452009388
3
+ metadata.gz: 75476fbf8271b8744fdca486f31ddfff585ab0680e29c600e25393d24b6b6fbb
4
+ data.tar.gz: 6092909983069ec5689e4e6ada0d241e632331fd6c97fb94e4ce5029c4cf228a
5
5
  SHA512:
6
- metadata.gz: eb15b72956d34a954d829f1ac5666568e5b0c1b4eeebd7a127d8f88b5d103efbd856fe0d4466c675826868a2e8fb8fd572004891753a0bf1799ba3bc04a1cc81
7
- data.tar.gz: 86ac266abcbe74dbf7fb72c833def9283fdd5943c0666fc1bf6dbed34008161c3458d85f1d365f7b8d6aacdd4af1a5b8822bc792c33de094668490d6108d4a87
6
+ metadata.gz: ccade0feb4bcc259e6bd0d3435799960014f204031b564ca88892e5c80afce36997f266b91acb0eb2af1e64f80488af49166fe4a99ab2d31874cac9827e37eaa
7
+ data.tar.gz: 5e74d777bfd30f8eed957d5ed07d11ffab8e6d4d462194277b7a38c28b70728445d0c598a26173020fc1d26c3c11410aabc6cf7f79f5a06cd806546a687cff7e
data/README.md CHANGED
@@ -1,91 +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
- 2. use `#response_cache` method to any desired controller's action
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
- class PostsController < ApplicationController
29
- def show
30
- response_cache do
31
- @post = @shop.posts.find(params[:id])
32
- respond_with(@post)
27
+ ```ruby
28
+ require 'response_bank'
29
+
30
+ module ResponseBank
31
+ LOCK_TTL = 90
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)
36
+ end
37
+ end
33
38
  end
34
- end
35
- end
36
- ```
37
39
 
38
- 3. **(optional)** override custom cache key data. For default, cache key is defined by URL and query string
40
+ ResponseBank.cache_store = ActiveSupport::Cache.lookup_store(Rails.configuration.cache_store)
41
+ ResponseBank.logger = Rails.logger
42
+
43
+ ```
44
+
45
+ 3. enables caching on your application
39
46
 
40
- ```ruby
41
- class PostsController < ApplicationController
42
- before_action :set_shop
47
+ ```ruby
48
+ config.action_controller.perform_caching = true
49
+ ```
43
50
 
44
- def index
45
- response_cache do
46
- @post = @shop.posts
47
- respond_with(@post)
51
+ 4. use `#response_cache` method to any desired controller's action
52
+
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
48
61
  end
49
- end
62
+ ```
63
+
64
+ 5. **(optional)** set a custom TTL for the cache by overriding the `write_to_backing_cache_store` method in your initializer file
50
65
 
51
- def show
52
- response_cache do
53
- @post = @shop.posts.find(params[:id])
54
- respond_with(@post)
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
55
72
  end
56
- end
57
-
58
- def another_action
59
- # custom cache key data
60
- cache_key = {
61
- action: action_name,
62
- format: request.format,
63
- shop_updated_at: @shop.updated_at
64
- # you may add more keys here
65
- }
66
- response_cache cache_key do
67
- @post = @shop.posts.find(params[:id])
68
- respond_with(@post)
73
+ ```
74
+
75
+ 6. **(optional)** override custom cache key data. For default, cache key is defined by URL and query string
76
+
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
69
123
  end
70
- end
71
-
72
- # override default cache key data globally per class
73
- def cache_key_data
74
- {
75
- action: action_name,
76
- format: request.format,
77
- params: params.slice(:id),
78
- shop_version: @shop.version
79
- # you may add more keys here
80
- }
81
- end
82
-
83
- def set_shop
84
- # @shop = ...
85
- end
86
- end
87
- ```
88
-
89
- ### License
124
+ ```
125
+
126
+ ## License
90
127
 
91
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,17 +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
- ResponseBank.cache_store.write(env['cacheable.key'], payload, raw: true)
48
-
49
- if env['cacheable.unversioned-key']
50
- ResponseBank.cache_store.write(env['cacheable.unversioned-key'], payload, raw: true)
51
- end
50
+ ResponseBank.write_to_backing_cache_store(
51
+ env,
52
+ env['cacheable.unversioned-key'],
53
+ payload,
54
+ expires_in: env['cacheable.versioned-cache-expiry'],
55
+ )
52
56
  end
53
57
 
54
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,8 +106,8 @@ module ResponseBank
126
106
  end
127
107
  end
128
108
 
129
- def serve_from_cache(cache_key_hash, message, cache_age_tolerance = nil)
130
- raw = @cache_store.read(cache_key_hash)
109
+ def serve_from_cache(cache_key_hash, match_entity_tag = "*", cache_age_tolerance = nil)
110
+ raw = ResponseBank.read_from_backing_cache_store(@env, cache_key_hash, backing_cache_store: @cache_store)
131
111
 
132
112
  if raw
133
113
  hit = MessagePack.load(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.0.0"
3
+ VERSION = "1.2.0"
4
4
  end
data/lib/response_bank.rb CHANGED
@@ -21,6 +21,14 @@ module ResponseBank
21
21
  yield
22
22
  end
23
23
 
24
+ def write_to_backing_cache_store(_env, key, payload, expires_in: nil)
25
+ cache_store.write(key, payload, raw: true, expires_in: expires_in)
26
+ end
27
+
28
+ def read_from_backing_cache_store(_env, cache_key, backing_cache_store: cache_store)
29
+ backing_cache_store.read(cache_key, raw: true)
30
+ end
31
+
24
32
  def compress(content)
25
33
  io = StringIO.new
26
34
  gz = Zlib::GzipWriter.new(io)
@@ -41,17 +49,17 @@ module ResponseBank
41
49
 
42
50
  key = hash_value_str(data[:key])
43
51
 
44
- return key unless data.key?(:version)
52
+ key = %{#{data[:key_schema_version]}:#{key}} if data[:key_schema_version]
45
53
 
46
- version = hash_value_str(data[:version])
54
+ key = %{#{key}:#{hash_value_str(data[:version])}} if data[:version]
47
55
 
48
- [key, version].join(":")
56
+ key
49
57
  when Array
50
58
  data.inspect
51
59
  when Time, DateTime
52
60
  data.to_i
53
61
  when Date
54
- data.to_time.to_i
62
+ data.to_s # Date#to_i does not support timezones, using iso8601 instead
55
63
  when true, false, Integer, Symbol, String
56
64
  data.inspect
57
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.0.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: 2020-01-27 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
@@ -129,7 +115,8 @@ files:
129
115
  homepage: ''
130
116
  licenses:
131
117
  - MIT
132
- metadata: {}
118
+ metadata:
119
+ allowed_push_host: https://rubygems.org
133
120
  post_install_message:
134
121
  rdoc_options: []
135
122
  require_paths:
@@ -138,14 +125,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
138
125
  requirements:
139
126
  - - ">="
140
127
  - !ruby/object:Gem::Version
141
- version: 2.4.0
128
+ version: 2.7.0
142
129
  required_rubygems_version: !ruby/object:Gem::Requirement
143
130
  requirements:
144
131
  - - ">="
145
132
  - !ruby/object:Gem::Version
146
133
  version: '0'
147
134
  requirements: []
148
- rubygems_version: 3.1.2
135
+ rubygems_version: 3.4.9
149
136
  signing_key:
150
137
  specification_version: 4
151
138
  summary: Simple response caching for Ruby applications