response_bank 1.3.8 → 1.4.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 +4 -4
- data/README.md +49 -0
- data/lib/response_bank/cache_policy.rb +6 -0
- data/lib/response_bank/cache_writer.rb +117 -0
- data/lib/response_bank/deferred_store.rb +165 -0
- data/lib/response_bank/middleware.rb +44 -69
- data/lib/response_bank/response_cache_handler.rb +31 -8
- data/lib/response_bank/version.rb +1 -1
- data/lib/response_bank.rb +18 -2
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9667d810b487f5db154d43ba2e7be769ab999d0d721b881a5c558e97ae55ceb
|
|
4
|
+
data.tar.gz: 744ec28ba5bdd1e5ae368e215d0ca6e86d0ce0d8910c49cd13b5835fe4283e9b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec9cb3918c4ff1d9bf6b5d1e18c18eb6bae8f0a49cdc8f15aff494c7d43fc2b3e1bcd38078770d75cfb06f4517f08515b67a8429877d815952576703461eb929
|
|
7
|
+
data.tar.gz: 9d815e56c3d9986367847c1f2f730c16fa72ab989dcedcf379cd849dd00a436076c7a08b92a94e0123ded75058d628d17b2a6dcab26fc6598a7057140daabfb7
|
data/README.md
CHANGED
|
@@ -123,6 +123,32 @@ This gem supports the following versions of Ruby and Rails:
|
|
|
123
123
|
end
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
## Completing a deferred cache miss
|
|
127
|
+
|
|
128
|
+
`ResponseBank::Middleware` can cache a body only when it is complete before the Rack tuple returns. An application that finishes a response later, such as through Rack partial hijack, can defer the cache write during a real ResponseBank miss:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
deferred_store = ResponseBank.defer_store(env)
|
|
132
|
+
|
|
133
|
+
# Return the Rack response. Later, after the body was generated and written in full:
|
|
134
|
+
cache_headers = headers.dup
|
|
135
|
+
cache_headers.delete('Cache-Control') if cache_headers['Cache-Control'] == 'no-cache, no-store'
|
|
136
|
+
deferred_store.complete(headers: cache_headers, body: completed_body)
|
|
137
|
+
|
|
138
|
+
# Run this from the response-finished path. It is a no-op after completion.
|
|
139
|
+
deferred_store.abort
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`defer_store` is a cache-fill lifecycle API. Call it only from a ResponseBank cache-miss path after `ResponseCacheHandler` initialized the request and recorded logical fill-lock ownership. It raises if the request is not a GET cache miss, if required cache context is absent, or if another deferred store is already registered.
|
|
143
|
+
|
|
144
|
+
The middleware arms the handle after the application returns the Rack tuple. It does not add an ETag to the live deferred response. `complete` copies and filters the final cache headers, adds the cached ETag and `Content-Encoding`, compresses the complete body, and writes the existing MessagePack cache format. It returns `true` when it stores and `false` when the request no longer owns an eligible fill.
|
|
145
|
+
|
|
146
|
+
Call `complete` only after the intended response was generated and written successfully. Never call it from a rescue or ensure path. Do not complete failed, timed-out, disconnected, or truncated responses. The body must be the shared cache representation and must not contain client-specific data added for the live response.
|
|
147
|
+
|
|
148
|
+
The headers passed to `complete` describe the cached representation. They can differ from headers already sent to the client. ResponseBank rejects final cache headers that contain `private` or `no-store`, and it does not mutate the supplied hash. It captures the cache timestamp when `defer_store` is called, before deferred rendering and compression.
|
|
149
|
+
|
|
150
|
+
`abort` is idempotent and releases an owned fill lock through `ResponseBank.release_lock`. Its default implementation is a no-op. The existing `write_to_cache` hook remains responsible for cleanup after a write attempt. An integration that releases those fills from `write_to_cache` should also implement `release_lock` for abandoned fills and failures that happen before the write hook. A key-only lock cannot prevent an old fill from releasing a replacement lock after its lease expires; integrations that need that guarantee must use owner tokens in their lock implementation.
|
|
151
|
+
|
|
126
152
|
## Brotli Splice Slots
|
|
127
153
|
|
|
128
154
|
Applications that need per-request replacement inside cached Brotli HTML responses can pass an injector builder to `ResponseBank::Middleware`:
|
|
@@ -241,6 +267,29 @@ Advanced integrations can still install the per-request injector directly in the
|
|
|
241
267
|
env[ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY] = injector
|
|
242
268
|
```
|
|
243
269
|
|
|
270
|
+
## Exception Handling
|
|
271
|
+
|
|
272
|
+
ResponseBank handles all exceptions gracefully during cache operations. If an exception occurs while reading from cache, deserializing cached data, or writing to cache, the middleware will:
|
|
273
|
+
|
|
274
|
+
1. **On cache read failures**: Fall back to rendering the page normally (as if it was a cache miss).
|
|
275
|
+
2. **On cache write failures**: Still serve the successfully rendered page to the user, but log the cache write failure.
|
|
276
|
+
|
|
277
|
+
This ensures that issues with cache stores (Redis/Memcached down), serialization errors, or compression/decompression failures don't cause 500 errors for your users.
|
|
278
|
+
|
|
279
|
+
### Custom Exception Handlers
|
|
280
|
+
|
|
281
|
+
You can set a custom exception handler in the Rack environment to be notified when cache operations fail (e.g., to report to Bugsnag, Sentry, etc.):
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
# In an initializer or middleware
|
|
285
|
+
class MyMiddleware
|
|
286
|
+
def call(env)
|
|
287
|
+
env['response_bank.on_exception'] = ->(e) { Bugsnag.notify(e) }
|
|
288
|
+
@app.call(env)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
```
|
|
292
|
+
|
|
244
293
|
## License
|
|
245
294
|
|
|
246
295
|
ResponseBank is released under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ResponseBank
|
|
4
|
+
CACHEABLE_HEADERS = ["Location", "Content-Type", "ETag", "Content-Encoding", "Last-Modified", "Cache-Control", "Expires", "Link", "Surrogate-Keys", "Cache-Tags", "Speculation-Rules"].freeze
|
|
5
|
+
CACHEABLE_STATUSES = [200, 404, 301].freeze
|
|
6
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'response_bank/brotli_splice_slot'
|
|
4
|
+
require 'response_bank/cache_policy'
|
|
5
|
+
require 'msgpack'
|
|
6
|
+
|
|
7
|
+
module ResponseBank
|
|
8
|
+
class CacheWriter
|
|
9
|
+
Stored = Struct.new(:body, :compressed_body, :metadata, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def flatten(body)
|
|
13
|
+
if body.is_a?(String)
|
|
14
|
+
body
|
|
15
|
+
elsif body.instance_of?(Array) && body.size == 1 && body[0].is_a?(String)
|
|
16
|
+
body[0]
|
|
17
|
+
else
|
|
18
|
+
result = +''
|
|
19
|
+
body.each { |part| result << part }
|
|
20
|
+
result
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def store(
|
|
25
|
+
env,
|
|
26
|
+
status:,
|
|
27
|
+
headers:,
|
|
28
|
+
body:,
|
|
29
|
+
timestamp:,
|
|
30
|
+
content_encoding: env.fetch('response_bank.server_cache_encoding'),
|
|
31
|
+
before_write: nil
|
|
32
|
+
)
|
|
33
|
+
cache_key = env.fetch('cacheable.key')
|
|
34
|
+
unversioned_key = env.fetch('cacheable.unversioned-key')
|
|
35
|
+
representation_headers = headers.slice(*ResponseBank::CACHEABLE_HEADERS)
|
|
36
|
+
representation_headers['ETag'] = %{"#{cache_key}"}
|
|
37
|
+
stored = prepare_body(env, representation_headers, body, content_encoding)
|
|
38
|
+
generated_at = timestamp.respond_to?(:call) ? timestamp.call : timestamp
|
|
39
|
+
data = cache_data(status, representation_headers, stored, env, generated_at, content_encoding)
|
|
40
|
+
|
|
41
|
+
before_write&.call
|
|
42
|
+
ResponseBank.write_to_cache(cache_key) do
|
|
43
|
+
payload = MessagePack.dump(data)
|
|
44
|
+
ResponseBank.write_to_backing_cache_store(
|
|
45
|
+
env,
|
|
46
|
+
unversioned_key,
|
|
47
|
+
payload,
|
|
48
|
+
expires_in: env['cacheable.versioned-cache-expiry'],
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
stored
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def prepare_body(env, headers, body, content_encoding)
|
|
58
|
+
body = flatten(body)
|
|
59
|
+
return Stored.new(body: body) if body.empty?
|
|
60
|
+
|
|
61
|
+
representation_headers = headers.merge('Content-Encoding' => content_encoding)
|
|
62
|
+
compression_level = ResponseBank.compression_level_for_request(env, representation_headers)
|
|
63
|
+
env['cacheable.compression_level'] = compression_level
|
|
64
|
+
body_compressed = nil
|
|
65
|
+
metadata = nil
|
|
66
|
+
time = ResponseBank.measure do
|
|
67
|
+
encoded_body = encode_spliced_body(
|
|
68
|
+
env,
|
|
69
|
+
body,
|
|
70
|
+
representation_headers,
|
|
71
|
+
content_encoding,
|
|
72
|
+
compression_level,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if encoded_body
|
|
76
|
+
body = encoded_body.body
|
|
77
|
+
body_compressed = encoded_body.compressed_body
|
|
78
|
+
metadata = encoded_body.metadata
|
|
79
|
+
else
|
|
80
|
+
body_compressed = ResponseBank.compress(
|
|
81
|
+
body,
|
|
82
|
+
content_encoding,
|
|
83
|
+
compression_level: compression_level,
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
ResponseBank.log("Compression time: #{time}ms")
|
|
88
|
+
env['cacheable.compression_time'] = time
|
|
89
|
+
|
|
90
|
+
Stored.new(body: body, compressed_body: body_compressed, metadata: metadata)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def encode_spliced_body(env, body, headers, content_encoding, compression_level)
|
|
94
|
+
return unless content_encoding == 'br'
|
|
95
|
+
|
|
96
|
+
ResponseBank::BrotliSpliceSlot.encode_body(
|
|
97
|
+
env,
|
|
98
|
+
body,
|
|
99
|
+
headers,
|
|
100
|
+
compression_level: compression_level,
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cache_data(status, representation_headers, stored, env, timestamp, content_encoding)
|
|
105
|
+
if stored.compressed_body
|
|
106
|
+
representation_headers['Content-Encoding'] = content_encoding
|
|
107
|
+
else
|
|
108
|
+
representation_headers.delete('Content-Encoding')
|
|
109
|
+
end
|
|
110
|
+
cached_headers = representation_headers.slice(*ResponseBank::CACHEABLE_HEADERS)
|
|
111
|
+
data = [status, cached_headers, stored.compressed_body, timestamp, env['cacheable.compression_level']]
|
|
112
|
+
data << stored.metadata if stored.metadata
|
|
113
|
+
data
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'response_bank/cache_policy'
|
|
4
|
+
require 'response_bank/cache_writer'
|
|
5
|
+
|
|
6
|
+
module ResponseBank
|
|
7
|
+
class DeferredStore
|
|
8
|
+
ENV_KEY = 'response_bank.deferred_store'
|
|
9
|
+
LOCK_OWNED_ENV_KEY = 'response_bank.fill_lock_owned'
|
|
10
|
+
TERMINAL_STATES = %i[aborted completing consumed].freeze
|
|
11
|
+
PRIVATE_CACHE_DIRECTIVES = %w[private no-store].freeze
|
|
12
|
+
|
|
13
|
+
class InvalidContextError < ArgumentError; end
|
|
14
|
+
class StateError < StandardError; end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def create(env, timestamp:)
|
|
18
|
+
validate_context!(env)
|
|
19
|
+
raise InvalidContextError, 'a deferred store is already registered for this request' if env.key?(ENV_KEY)
|
|
20
|
+
|
|
21
|
+
new(env, timestamp: timestamp).tap { |store| env[ENV_KEY] = store }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def from_env(env)
|
|
25
|
+
env[ENV_KEY]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def arm_from_middleware(env, status:, headers:)
|
|
29
|
+
from_env(env)&.__send__(:arm, status: status, headers: headers)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def validate_context!(env)
|
|
35
|
+
raise InvalidContextError, 'deferred storage requires a cache miss' unless cache_miss?(env)
|
|
36
|
+
raise InvalidContextError, 'deferred storage requires a GET request' unless env['REQUEST_METHOD'] == 'GET'
|
|
37
|
+
|
|
38
|
+
['cacheable.key', 'cacheable.unversioned-key', 'response_bank.server_cache_encoding'].each do |key|
|
|
39
|
+
raise InvalidContextError, "deferred storage requires #{key}" unless env[key]
|
|
40
|
+
end
|
|
41
|
+
unless env.key?(LOCK_OWNED_ENV_KEY)
|
|
42
|
+
raise InvalidContextError, "deferred storage requires #{LOCK_OWNED_ENV_KEY}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cache_miss?(env)
|
|
47
|
+
env['cacheable.cache'] && env['cacheable.miss']
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(env, timestamp:)
|
|
52
|
+
@env = env
|
|
53
|
+
@timestamp = timestamp
|
|
54
|
+
@cache_key = env.fetch('cacheable.key')
|
|
55
|
+
@owns_lock = env.fetch(LOCK_OWNED_ENV_KEY) == true
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
@state = :requested
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `body` and `headers` must describe the complete shared cache representation,
|
|
61
|
+
# not a partial response or bytes personalized for the live client.
|
|
62
|
+
def complete(body:, headers: nil)
|
|
63
|
+
status, cached_headers, release_lock = prepare_completion(headers)
|
|
64
|
+
|
|
65
|
+
if release_lock
|
|
66
|
+
release_owned_lock
|
|
67
|
+
return false
|
|
68
|
+
end
|
|
69
|
+
return false unless status
|
|
70
|
+
|
|
71
|
+
write_started = false
|
|
72
|
+
completed = false
|
|
73
|
+
begin
|
|
74
|
+
CacheWriter.store(
|
|
75
|
+
@env,
|
|
76
|
+
status: status,
|
|
77
|
+
headers: cached_headers,
|
|
78
|
+
body: body,
|
|
79
|
+
timestamp: @timestamp,
|
|
80
|
+
before_write: -> { write_started = true },
|
|
81
|
+
)
|
|
82
|
+
completed = true
|
|
83
|
+
ensure
|
|
84
|
+
@mutex.synchronize { @state = :consumed }
|
|
85
|
+
@env['cacheable.locked'] = false if @owns_lock
|
|
86
|
+
release_owned_lock if @owns_lock && !completed && !write_started
|
|
87
|
+
end
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def abort
|
|
92
|
+
transitioned, release_lock = @mutex.synchronize do
|
|
93
|
+
if TERMINAL_STATES.include?(@state)
|
|
94
|
+
[false, false]
|
|
95
|
+
else
|
|
96
|
+
@state = :aborted
|
|
97
|
+
[true, @owns_lock]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
release_owned_lock if release_lock
|
|
102
|
+
transitioned
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def arm(status:, headers:)
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
if @state != :aborted
|
|
110
|
+
raise StateError, "cannot arm a deferred store in the #{@state} state" unless @state == :requested
|
|
111
|
+
|
|
112
|
+
@status = status
|
|
113
|
+
@headers = headers.slice(*ResponseBank::CACHEABLE_HEADERS)
|
|
114
|
+
@eligible = @owns_lock && cache_miss? && status_cacheable?(status)
|
|
115
|
+
@state = :armed
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def prepare_completion(headers)
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
raise StateError, 'the deferred store has not been armed by the middleware' if @state == :requested
|
|
125
|
+
return [nil, nil, false] if @state == :aborted
|
|
126
|
+
raise StateError, "cannot complete a deferred store in the #{@state} state" unless @state == :armed
|
|
127
|
+
|
|
128
|
+
cached_headers = (headers || @headers).slice(*ResponseBank::CACHEABLE_HEADERS)
|
|
129
|
+
if completion_eligible?(cached_headers)
|
|
130
|
+
@state = :completing
|
|
131
|
+
[@status, cached_headers, false]
|
|
132
|
+
else
|
|
133
|
+
@state = :aborted
|
|
134
|
+
[nil, nil, @owns_lock]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def completion_eligible?(headers)
|
|
140
|
+
@eligible && cache_miss? && cache_control_allows_storage?(headers)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def cache_miss?
|
|
144
|
+
@env['cacheable.cache'] && @env['cacheable.miss']
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def status_cacheable?(status)
|
|
148
|
+
ResponseBank::CACHEABLE_STATUSES.include?(status)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def cache_control_allows_storage?(headers)
|
|
152
|
+
value = headers['Cache-Control']
|
|
153
|
+
return true unless value
|
|
154
|
+
|
|
155
|
+
directives = value.split(',').map { |directive| directive.strip.downcase.split('=', 2).first }
|
|
156
|
+
(directives & PRIVATE_CACHE_DIRECTIVES).empty?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def release_owned_lock
|
|
160
|
+
ResponseBank.release_lock(@cache_key)
|
|
161
|
+
ensure
|
|
162
|
+
@env['cacheable.locked'] = false
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'response_bank/brotli_splice_slot'
|
|
3
|
+
require 'response_bank/cache_policy'
|
|
4
|
+
require 'response_bank/cache_writer'
|
|
5
|
+
require 'response_bank/deferred_store'
|
|
3
6
|
|
|
4
7
|
module ResponseBank
|
|
5
8
|
class Middleware
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
CACHEABLE_HEADERS = ["Location", "Content-Type", "ETag", "Content-Encoding", "Last-Modified", "Cache-Control", "Expires", "Link", "Surrogate-Keys", "Cache-Tags", "Speculation-Rules"].freeze
|
|
9
|
+
CACHEABLE_HEADERS = ResponseBank::CACHEABLE_HEADERS
|
|
10
|
+
CACHEABLE_STATUSES = ResponseBank::CACHEABLE_STATUSES
|
|
9
11
|
|
|
10
12
|
REQUESTED_WITH = "HTTP_X_REQUESTED_WITH"
|
|
11
13
|
ACCEPT = "HTTP_ACCEPT"
|
|
@@ -22,81 +24,55 @@ module ResponseBank
|
|
|
22
24
|
|
|
23
25
|
content_encoding = env['response_bank.server_cache_encoding'] = ResponseBank.check_encoding(env)
|
|
24
26
|
|
|
25
|
-
status, headers, body =
|
|
27
|
+
status, headers, body = begin
|
|
28
|
+
@app.call(env)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
ResponseBank::DeferredStore.from_env(env)&.abort
|
|
31
|
+
raise
|
|
32
|
+
end
|
|
33
|
+
deferred_store = ResponseBank::DeferredStore.from_env(env)
|
|
34
|
+
ResponseBank::DeferredStore.arm_from_middleware(env, status: status, headers: headers)
|
|
26
35
|
|
|
27
36
|
if env['cacheable.cache']
|
|
28
|
-
if [200, 404, 301, 304].include?(status)
|
|
37
|
+
if [200, 404, 301, 304].include?(status) && !deferred_store
|
|
29
38
|
headers['ETag'] = %{"#{env['cacheable.key']}"}
|
|
30
39
|
end
|
|
31
40
|
|
|
32
|
-
if
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
body_string = +""
|
|
38
|
-
body.each { |part| body_string << part }
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
body_compressed = nil
|
|
42
|
-
metadata = nil
|
|
43
|
-
if body_string && body_string != ""
|
|
44
|
-
headers['Content-Encoding'] = content_encoding
|
|
45
|
-
env["cacheable.compression_level"] = ResponseBank.compression_level_for_request(env, headers)
|
|
46
|
-
time = ResponseBank.measure do
|
|
47
|
-
encoded_body = if content_encoding == 'br'
|
|
48
|
-
ResponseBank::BrotliSpliceSlot.encode_body(
|
|
49
|
-
env,
|
|
50
|
-
body_string,
|
|
51
|
-
headers,
|
|
52
|
-
compression_level: env["cacheable.compression_level"],
|
|
53
|
-
)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
if encoded_body
|
|
57
|
-
body_string = encoded_body.body
|
|
58
|
-
body_compressed = encoded_body.compressed_body
|
|
59
|
-
metadata = encoded_body.metadata
|
|
60
|
-
else
|
|
61
|
-
body_compressed = ResponseBank.compress(
|
|
62
|
-
body_string,
|
|
63
|
-
content_encoding,
|
|
64
|
-
compression_level: env["cacheable.compression_level"],
|
|
65
|
-
)
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
ResponseBank.log("Compression time: #{time}ms")
|
|
69
|
-
env["cacheable.compression_time"] = time
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
cached_headers = headers.slice(*CACHEABLE_HEADERS)
|
|
73
|
-
# Store result
|
|
74
|
-
cache_data = [status, cached_headers, body_compressed, timestamp, env["cacheable.compression_level"]]
|
|
75
|
-
cache_data << metadata if metadata
|
|
76
|
-
|
|
77
|
-
ResponseBank.write_to_cache(env['cacheable.key']) do
|
|
78
|
-
payload = MessagePack.dump(cache_data)
|
|
79
|
-
ResponseBank.write_to_backing_cache_store(
|
|
41
|
+
if CACHEABLE_STATUSES.include?(status) && env['cacheable.miss'] && !deferred_store
|
|
42
|
+
body_string = CacheWriter.flatten(body)
|
|
43
|
+
stored = nil
|
|
44
|
+
begin
|
|
45
|
+
stored = CacheWriter.store(
|
|
80
46
|
env,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
47
|
+
status: status,
|
|
48
|
+
headers: headers,
|
|
49
|
+
body: body_string,
|
|
50
|
+
timestamp: -> { timestamp },
|
|
51
|
+
content_encoding: content_encoding,
|
|
84
52
|
)
|
|
85
|
-
end
|
|
86
53
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
54
|
+
if stored.compressed_body
|
|
55
|
+
if env['HTTP_ACCEPT_ENCODING'].to_s.include?(content_encoding)
|
|
56
|
+
headers['Content-Encoding'] = content_encoding
|
|
57
|
+
if content_encoding == 'br'
|
|
58
|
+
body = [ResponseBank::BrotliSpliceSlot.replace_compressed_secret(env, stored.compressed_body, stored.metadata)]
|
|
59
|
+
else
|
|
60
|
+
body = [stored.compressed_body]
|
|
61
|
+
end
|
|
93
62
|
else
|
|
94
|
-
|
|
63
|
+
headers.delete('Content-Encoding')
|
|
64
|
+
body = [ResponseBank::BrotliSpliceSlot.replace_plain_body(env, stored.body, stored.metadata)] if stored.metadata
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
rescue => exception
|
|
68
|
+
headers.delete('Content-Encoding') if stored&.compressed_body
|
|
69
|
+
ResponseBank.log("Failed to write to cache: #{exception.class} - #{exception.message}")
|
|
70
|
+
if env['response_bank.on_exception']
|
|
71
|
+
begin
|
|
72
|
+
env['response_bank.on_exception'].call(exception)
|
|
73
|
+
rescue => handler_exception
|
|
74
|
+
ResponseBank.log("Exception handler failed: #{handler_exception.class} - #{handler_exception.message}")
|
|
95
75
|
end
|
|
96
|
-
else
|
|
97
|
-
# Remove content-encoding header for response with compressed content
|
|
98
|
-
headers.delete('Content-Encoding')
|
|
99
|
-
body = [ResponseBank::BrotliSpliceSlot.replace_plain_body(env, body_string, metadata)] if metadata
|
|
100
76
|
end
|
|
101
77
|
end
|
|
102
78
|
end
|
|
@@ -129,6 +105,5 @@ module ResponseBank
|
|
|
129
105
|
def timestamp
|
|
130
106
|
Time.now.to_i
|
|
131
107
|
end
|
|
132
|
-
|
|
133
108
|
end
|
|
134
109
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'digest/md5'
|
|
3
3
|
require 'response_bank/brotli_splice_slot'
|
|
4
|
+
require 'response_bank/deferred_store'
|
|
4
5
|
|
|
5
6
|
module ResponseBank
|
|
6
7
|
class ResponseCacheHandler
|
|
@@ -83,18 +84,25 @@ module ResponseBank
|
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
def try_to_serve_from_cache
|
|
87
|
+
response = read_from_cache
|
|
88
|
+
return response if response
|
|
89
|
+
|
|
90
|
+
# No cache hit; this request cannot be handled from cache.
|
|
91
|
+
# Yield to the controller and mark for writing into cache.
|
|
92
|
+
refill_cache
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def read_from_cache
|
|
86
96
|
# Etag
|
|
87
97
|
unless @skip_browser_cache
|
|
88
98
|
response = serve_from_browser_cache(entity_tag_hash, @env['HTTP_IF_NONE_MATCH'])
|
|
89
99
|
return response if response
|
|
90
100
|
end
|
|
91
101
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# Yield to the controller and mark for writing into cache.
|
|
97
|
-
refill_cache
|
|
102
|
+
serve_from_cache(cache_key_hash, @serve_unversioned ? "*" : entity_tag_hash, @cache_age_tolerance)
|
|
103
|
+
rescue => exception
|
|
104
|
+
handle_cache_exception(exception)
|
|
105
|
+
nil
|
|
98
106
|
end
|
|
99
107
|
|
|
100
108
|
def serve_from_browser_cache(entity_tag, if_none_match)
|
|
@@ -137,6 +145,7 @@ module ResponseBank
|
|
|
137
145
|
if ResponseBank.acquire_lock(match_entity_tag)
|
|
138
146
|
# execute if we can get the lock
|
|
139
147
|
@env['cacheable.locked'] = true
|
|
148
|
+
@env[ResponseBank::DeferredStore::LOCK_OWNED_ENV_KEY] = true
|
|
140
149
|
return
|
|
141
150
|
elsif stale_while_revalidate?(timestamp, cache_age_tolerance)
|
|
142
151
|
# cache is being regenerated, can we avoid piling on and use a stale version in the interim?
|
|
@@ -201,8 +210,10 @@ module ResponseBank
|
|
|
201
210
|
end
|
|
202
211
|
|
|
203
212
|
def refill_cache
|
|
204
|
-
|
|
205
|
-
|
|
213
|
+
unless @env['cacheable.locked']
|
|
214
|
+
acquired = ResponseBank.acquire_lock(entity_tag_hash)
|
|
215
|
+
@env[ResponseBank::DeferredStore::LOCK_OWNED_ENV_KEY] = !!acquired
|
|
216
|
+
end
|
|
206
217
|
@env['cacheable.locked'] = true
|
|
207
218
|
@env['cacheable.miss'] = true
|
|
208
219
|
|
|
@@ -210,5 +221,17 @@ module ResponseBank
|
|
|
210
221
|
|
|
211
222
|
@cache_miss_block.call
|
|
212
223
|
end
|
|
224
|
+
|
|
225
|
+
def handle_cache_exception(exception)
|
|
226
|
+
ResponseBank.log("Cache operation failed: #{exception.class} - #{exception.message}")
|
|
227
|
+
|
|
228
|
+
if @env['response_bank.on_exception']
|
|
229
|
+
begin
|
|
230
|
+
@env['response_bank.on_exception'].call(exception)
|
|
231
|
+
rescue => handler_exception
|
|
232
|
+
ResponseBank.log("Exception handler failed: #{handler_exception.class} - #{handler_exception.message}")
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
213
236
|
end
|
|
214
237
|
end
|
data/lib/response_bank.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'response_bank/brotli_splice_injector'
|
|
3
3
|
require 'response_bank/brotli_splice_slot'
|
|
4
|
+
require 'response_bank/cache_policy'
|
|
5
|
+
require 'response_bank/cache_writer'
|
|
6
|
+
require 'response_bank/deferred_store'
|
|
4
7
|
require 'response_bank/middleware'
|
|
5
8
|
require 'response_bank/railtie' if defined?(Rails)
|
|
6
9
|
require 'response_bank/response_cache_handler'
|
|
@@ -9,14 +12,16 @@ require 'brotli'
|
|
|
9
12
|
require 'benchmark'
|
|
10
13
|
|
|
11
14
|
module ResponseBank
|
|
15
|
+
private_constant :CacheWriter
|
|
16
|
+
|
|
12
17
|
class << self
|
|
13
18
|
attr_accessor :cache_store
|
|
14
19
|
attr_writer :logger, :compression_level
|
|
15
20
|
|
|
16
21
|
DEFAULT_BROTLI_COMPRESSION_LEVEL = 7
|
|
17
22
|
|
|
18
|
-
DEFAULT_COMPRESSION_LEVEL = -> (
|
|
19
|
-
case
|
|
23
|
+
DEFAULT_COMPRESSION_LEVEL = -> (env, _headers) {
|
|
24
|
+
case env['response_bank.server_cache_encoding']
|
|
20
25
|
when 'br'
|
|
21
26
|
DEFAULT_BROTLI_COMPRESSION_LEVEL
|
|
22
27
|
when 'gzip'
|
|
@@ -40,6 +45,17 @@ module ResponseBank
|
|
|
40
45
|
raise NotImplementedError, "Override ResponseBank.acquire_lock in an initializer."
|
|
41
46
|
end
|
|
42
47
|
|
|
48
|
+
# Starts a one-shot deferred cache fill for the current ResponseBank miss.
|
|
49
|
+
# Complete it only after the intended shared response was generated and
|
|
50
|
+
# written successfully. Abort failed, disconnected or truncated responses.
|
|
51
|
+
def defer_store(env, timestamp: Time.now.to_i)
|
|
52
|
+
DeferredStore.create(env, timestamp: timestamp)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Override when deferred fills must release an application-managed lock.
|
|
56
|
+
def release_lock(_cache_key)
|
|
57
|
+
end
|
|
58
|
+
|
|
43
59
|
def write_to_cache(_key)
|
|
44
60
|
yield
|
|
45
61
|
end
|
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.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tobias Lütke
|
|
@@ -38,6 +38,20 @@ dependencies:
|
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: benchmark
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: brotli_splice
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -134,7 +148,10 @@ files:
|
|
|
134
148
|
- lib/response_bank.rb
|
|
135
149
|
- lib/response_bank/brotli_splice_injector.rb
|
|
136
150
|
- lib/response_bank/brotli_splice_slot.rb
|
|
151
|
+
- lib/response_bank/cache_policy.rb
|
|
152
|
+
- lib/response_bank/cache_writer.rb
|
|
137
153
|
- lib/response_bank/controller.rb
|
|
154
|
+
- lib/response_bank/deferred_store.rb
|
|
138
155
|
- lib/response_bank/middleware.rb
|
|
139
156
|
- lib/response_bank/model_extensions.rb
|
|
140
157
|
- lib/response_bank/railtie.rb
|
|
@@ -159,7 +176,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
159
176
|
- !ruby/object:Gem::Version
|
|
160
177
|
version: '0'
|
|
161
178
|
requirements: []
|
|
162
|
-
rubygems_version: 4.0.
|
|
179
|
+
rubygems_version: 4.0.16
|
|
163
180
|
specification_version: 4
|
|
164
181
|
summary: Simple response caching for Ruby applications
|
|
165
182
|
test_files: []
|