response_bank 1.3.7 → 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 +168 -1
- data/lib/response_bank/brotli_splice_injector.rb +17 -0
- data/lib/response_bank/brotli_splice_slot.rb +184 -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 +64 -50
- data/lib/response_bank/railtie.rb +10 -2
- data/lib/response_bank/response_cache_handler.rb +41 -12
- data/lib/response_bank/version.rb +1 -1
- data/lib/response_bank.rb +20 -2
- metadata +36 -3
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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
This gem supports the following versions of Ruby and Rails:
|
|
13
13
|
|
|
14
|
-
* Ruby
|
|
14
|
+
* Ruby 3.1.0+
|
|
15
15
|
* Rails 6.0.0+
|
|
16
16
|
|
|
17
17
|
## Usage
|
|
@@ -123,6 +123,173 @@ 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
|
+
|
|
152
|
+
## Brotli Splice Slots
|
|
153
|
+
|
|
154
|
+
Applications that need per-request replacement inside cached Brotli HTML responses can pass an injector builder to `ResponseBank::Middleware`:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
use ResponseBank::Middleware, ->(env) { HtmlMetadataInjector.new(env) }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Rails applications can configure the same builder through `config.response_bank`:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
config.response_bank.brotli_splice_injector =
|
|
164
|
+
->(env) { HtmlMetadataInjector.new(env) }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The injector is optional. If it is not configured, ResponseBank uses the normal Brotli compression path. Applications own the concrete injector implementation because they know how to read their request-specific metadata.
|
|
168
|
+
|
|
169
|
+
Injectors may include `ResponseBank::BrotliSpliceInjector` to document the required methods:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class HtmlMetadataInjector
|
|
173
|
+
include ResponseBank::BrotliSpliceInjector
|
|
174
|
+
|
|
175
|
+
# The per-request value spliced in on cache hits (a 36-byte UUID).
|
|
176
|
+
TOKEN_PLACEHOLDER = "00000000-0000-0000-0000-000000000000"
|
|
177
|
+
# BrotliSplice reserves the LAST 2 bytes of a slot as a fixed "\r\n" context
|
|
178
|
+
# suffix, so a slot must span 2 more bytes than its replaceable region and
|
|
179
|
+
# `replacement_length` always comes back as `slot length - 2`. We let those
|
|
180
|
+
# 2 bytes be a real "\r\n" placed after the tag, where a line break is
|
|
181
|
+
# harmless — the replaceable region is therefore `<uuid>">` (38 bytes).
|
|
182
|
+
CONTEXT_SUFFIX = "\r\n"
|
|
183
|
+
PLACEHOLDER_TAG = %(<meta name="shopify-y" content="#{TOKEN_PLACEHOLDER}">#{CONTEXT_SUFFIX})
|
|
184
|
+
SLOT = %(#{TOKEN_PLACEHOLDER}">#{CONTEXT_SUFFIX}) # 38 replaceable bytes + 2 reserved
|
|
185
|
+
|
|
186
|
+
def initialize(env)
|
|
187
|
+
@env = env
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def prepare_response_bank_brotli_splice(body, _headers)
|
|
191
|
+
body_with_placeholder = body.sub("</head>", "#{PLACEHOLDER_TAG}</head>")
|
|
192
|
+
# Use a byte offset: BrotliSplice.encode addresses the slot by bytes.
|
|
193
|
+
offset = body_with_placeholder.b.index(TOKEN_PLACEHOLDER)
|
|
194
|
+
return unless offset
|
|
195
|
+
|
|
196
|
+
{
|
|
197
|
+
body: body_with_placeholder,
|
|
198
|
+
slots: [
|
|
199
|
+
{
|
|
200
|
+
name: "shopify_y",
|
|
201
|
+
offset: offset,
|
|
202
|
+
# Include the 2 bytes reserved for the context suffix, or the
|
|
203
|
+
# replacement below will be 2 bytes too long and silently dropped.
|
|
204
|
+
length: SLOT.bytesize,
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def response_bank_brotli_splice_replacement(slot)
|
|
211
|
+
# Must return EXACTLY slot["replacement_length"] bytes (== slot length - 2).
|
|
212
|
+
# If it does not, ResponseBank skips the splice and serves the neutral
|
|
213
|
+
# placeholder, so guard the length rather than assuming it.
|
|
214
|
+
replacement = %(#{shopify_y}">)
|
|
215
|
+
return unless replacement.bytesize == slot.fetch("replacement_length")
|
|
216
|
+
|
|
217
|
+
replacement
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def replace_response_bank_brotli_splice_placeholders(body, slots)
|
|
221
|
+
slots.reduce(body) do |current, slot|
|
|
222
|
+
replacement = response_bank_brotli_splice_replacement(slot)
|
|
223
|
+
next current unless replacement
|
|
224
|
+
|
|
225
|
+
offset = slot.fetch("html_placeholder_offset")
|
|
226
|
+
length = slot.fetch("html_placeholder_length")
|
|
227
|
+
suffix = slot.fetch("context_suffix", CONTEXT_SUFFIX)
|
|
228
|
+
|
|
229
|
+
# replacement + suffix must equal the original slot length (byteslice
|
|
230
|
+
# replaces `length` bytes), keeping the body byte-for-byte consistent.
|
|
231
|
+
current.byteslice(0, offset) + replacement + suffix +
|
|
232
|
+
current.byteslice(offset + length, current.bytesize)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
def shopify_y
|
|
239
|
+
@env.fetch("HTTP_SHOPIFY_Y") # 36-byte UUID
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`prepare_response_bank_brotli_splice` is used on cache writes. It returns HTML
|
|
245
|
+
containing a neutral placeholder and one slot describing that placeholder.
|
|
246
|
+
ResponseBank stores the slot metadata with the cached Brotli body. The slot's
|
|
247
|
+
`offset` and `length` are **byte** offsets/counts — `BrotliSplice.encode`
|
|
248
|
+
addresses the stream by bytes — so locate the placeholder on a binary view
|
|
249
|
+
(`body.b.index(...)`) and size it with `bytesize`. A plain `String#index`/`size`
|
|
250
|
+
silently breaks once any multi-byte UTF-8 precedes the placeholder: a character
|
|
251
|
+
offset is always ≤ the byte length, so the bounds check still passes.
|
|
252
|
+
|
|
253
|
+
`response_bank_brotli_splice_replacement` is used on Brotli cache hits. It must
|
|
254
|
+
return exactly `slot["replacement_length"]` bytes. Note that `replacement_length`
|
|
255
|
+
is **2 fewer** than the `length` you registered in the slot: `BrotliSplice.encode`
|
|
256
|
+
reserves the last 2 bytes of every slot as a fixed `\r\n` context suffix. So size
|
|
257
|
+
your placeholder to include those 2 bytes (as the example does with the trailing
|
|
258
|
+
`\r\n`), and have the replacement match `replacement_length`. If the byte length
|
|
259
|
+
does not match, ResponseBank **silently skips the splice and serves the neutral
|
|
260
|
+
placeholder** — no exception is raised — so guard the length instead of assuming it.
|
|
261
|
+
|
|
262
|
+
`replace_response_bank_brotli_splice_placeholders` is used when a cached Brotli response is decompressed for a client that does not accept Brotli.
|
|
263
|
+
|
|
264
|
+
Advanced integrations can still install the per-request injector directly in the Rack env before ResponseBank reads or writes the cached body:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
env[ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY] = injector
|
|
268
|
+
```
|
|
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
|
+
|
|
126
293
|
## License
|
|
127
294
|
|
|
128
295
|
ResponseBank is released under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ResponseBank
|
|
4
|
+
module BrotliSpliceInjector
|
|
5
|
+
def prepare_response_bank_brotli_splice(body, headers)
|
|
6
|
+
raise NotImplementedError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def response_bank_brotli_splice_replacement(slot)
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def replace_response_bank_brotli_splice_placeholders(body, slots)
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ResponseBank
|
|
4
|
+
module BrotliSpliceSlot
|
|
5
|
+
INJECTOR_ENV_KEY = 'response_bank.html_metadata_injector'
|
|
6
|
+
METADATA_KEY = 'brotli_splice'
|
|
7
|
+
METADATA_VERSION = 1
|
|
8
|
+
# BrotliSplice reserves the last 2 bytes of every slot as a fixed "\r\n" context
|
|
9
|
+
# suffix, so a slot must be longer than that to hold any replaceable content --
|
|
10
|
+
# BrotliSplice.encode raises unless the slot length exceeds it.
|
|
11
|
+
CONTEXT_SUFFIX_LENGTH = 2
|
|
12
|
+
|
|
13
|
+
EncodedBody = Struct.new(:body, :compressed_body, :metadata, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def encode_body(env, body, headers, compression_level:)
|
|
17
|
+
injector = env[INJECTOR_ENV_KEY]
|
|
18
|
+
return unless injector
|
|
19
|
+
return unless body && body != ''
|
|
20
|
+
|
|
21
|
+
prepared = injector.prepare_response_bank_brotli_splice(body, headers)
|
|
22
|
+
return unless prepared
|
|
23
|
+
|
|
24
|
+
prepared_body = hash_fetch(prepared, :body)
|
|
25
|
+
slots = hash_fetch(prepared, :slots)
|
|
26
|
+
return unless prepared_body && slots && slots.length == 1
|
|
27
|
+
|
|
28
|
+
slot = slots.first
|
|
29
|
+
slot_name = hash_fetch(slot, :name).to_s
|
|
30
|
+
html_offset = integer_value(hash_fetch(slot, :offset))
|
|
31
|
+
html_length = integer_value(hash_fetch(slot, :length))
|
|
32
|
+
return unless valid_html_slot?(prepared_body, html_offset, html_length)
|
|
33
|
+
|
|
34
|
+
# Load the native gem only once we have real splice work to do. Keeping this
|
|
35
|
+
# outside the begin/rescue below is deliberate: the rescue clause references
|
|
36
|
+
# BrotliSplice::Error, so if the gem is missing, evaluating that clause would
|
|
37
|
+
# raise NameError and mask the LoadError we want the caller to see.
|
|
38
|
+
ensure_brotli_splice_loaded!
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
result = BrotliSplice.encode(prepared_body, html_offset, html_length, quality: compression_level)
|
|
42
|
+
|
|
43
|
+
metadata_slot = {
|
|
44
|
+
'name' => slot_name,
|
|
45
|
+
'compressed_offset' => result[:secret_offset],
|
|
46
|
+
'replacement_length' => result[:secret_length],
|
|
47
|
+
'html_placeholder_offset' => html_offset,
|
|
48
|
+
'html_placeholder_length' => html_length,
|
|
49
|
+
}
|
|
50
|
+
metadata_slot['context_suffix'] = result[:context_suffix] if result[:context_suffix]
|
|
51
|
+
|
|
52
|
+
EncodedBody.new(
|
|
53
|
+
body: prepared_body,
|
|
54
|
+
compressed_body: result[:data],
|
|
55
|
+
metadata: {
|
|
56
|
+
METADATA_KEY => {
|
|
57
|
+
'version' => METADATA_VERSION,
|
|
58
|
+
'slots' => [metadata_slot],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
rescue BrotliSplice::Error, ArgumentError => error
|
|
63
|
+
ResponseBank.log("BrotliSplice encode skipped: #{error.class}")
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def replace_compressed_secret(env, body, metadata)
|
|
69
|
+
injector = env[INJECTOR_ENV_KEY]
|
|
70
|
+
slots = metadata_slots(metadata)
|
|
71
|
+
return body unless injector && slots && body && body != ''
|
|
72
|
+
|
|
73
|
+
# See encode_body: load outside the begin/rescue so a missing gem surfaces as
|
|
74
|
+
# LoadError rather than a masked NameError from the BrotliSplice::Error clause.
|
|
75
|
+
ensure_brotli_splice_loaded!
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
slots.reduce(body) do |current_body, slot|
|
|
79
|
+
replacement = replacement_for_slot(injector, slot)
|
|
80
|
+
|
|
81
|
+
unless valid_replacement?(replacement, slot)
|
|
82
|
+
got = replacement ? replacement.bytesize : 'nil'
|
|
83
|
+
ResponseBank.log(
|
|
84
|
+
'BrotliSplice replace skipped: replacement length mismatch ' \
|
|
85
|
+
"(got #{got}, want #{slot['replacement_length']})",
|
|
86
|
+
)
|
|
87
|
+
next current_body
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
offset = integer_value(slot['compressed_offset'])
|
|
91
|
+
length = integer_value(slot['replacement_length'])
|
|
92
|
+
|
|
93
|
+
unless valid_compressed_slot?(current_body, offset, length)
|
|
94
|
+
ResponseBank.log('BrotliSplice replace skipped: compressed slot out of bounds')
|
|
95
|
+
next current_body
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
BrotliSplice.replace(current_body, replacement, offset, length)
|
|
99
|
+
end
|
|
100
|
+
rescue BrotliSplice::Error, ArgumentError => error
|
|
101
|
+
ResponseBank.log("BrotliSplice replace skipped: #{error.class}")
|
|
102
|
+
body
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def replace_plain_body(env, body, metadata)
|
|
107
|
+
injector = env[INJECTOR_ENV_KEY]
|
|
108
|
+
slots = metadata_slots(metadata)
|
|
109
|
+
return body unless injector && slots && body && body != ''
|
|
110
|
+
|
|
111
|
+
injector.replace_response_bank_brotli_splice_placeholders(body, slots)
|
|
112
|
+
rescue ArgumentError => error
|
|
113
|
+
ResponseBank.log("BrotliSplice plain replacement skipped: #{error.class}")
|
|
114
|
+
body
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def metadata_slots(metadata)
|
|
118
|
+
return unless metadata
|
|
119
|
+
|
|
120
|
+
brotli_splice = metadata[METADATA_KEY]
|
|
121
|
+
return unless brotli_splice && brotli_splice['version'] == METADATA_VERSION
|
|
122
|
+
|
|
123
|
+
brotli_splice['slots']
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Load the native brotli_splice gem on first use. It is an optional dependency:
|
|
129
|
+
# apps opt into Brotli splice slots by installing an injector, and only those
|
|
130
|
+
# apps need the gem. If it is missing when we actually need it, fail loudly with
|
|
131
|
+
# a pointer to the fix rather than limping along.
|
|
132
|
+
def ensure_brotli_splice_loaded!
|
|
133
|
+
return if @brotli_splice_loaded
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
gem('brotli_splice')
|
|
137
|
+
require('brotli_splice')
|
|
138
|
+
rescue LoadError => error
|
|
139
|
+
warn(
|
|
140
|
+
'The Brotli splice slot feature requires the "brotli_splice" gem. ' \
|
|
141
|
+
'Add it to your application Gemfile.',
|
|
142
|
+
)
|
|
143
|
+
raise error
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@brotli_splice_loaded = true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def replacement_for_slot(injector, slot)
|
|
150
|
+
injector.response_bank_brotli_splice_replacement(slot)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def valid_replacement?(replacement, slot)
|
|
154
|
+
return false unless replacement
|
|
155
|
+
|
|
156
|
+
replacement.bytesize == slot['replacement_length']
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def valid_compressed_slot?(body, offset, length)
|
|
160
|
+
return false unless offset && length
|
|
161
|
+
return false unless offset >= 0 && length > 0
|
|
162
|
+
|
|
163
|
+
offset + length <= body.bytesize
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def valid_html_slot?(body, offset, length)
|
|
167
|
+
return false unless offset && length
|
|
168
|
+
return false unless offset >= 0 && length > CONTEXT_SUFFIX_LENGTH
|
|
169
|
+
|
|
170
|
+
offset + length <= body.bytesize
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def integer_value(value)
|
|
174
|
+
Integer(value)
|
|
175
|
+
rescue ArgumentError, TypeError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def hash_fetch(hash, key)
|
|
180
|
+
hash[key] || hash[key.to_s]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -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,76 +1,78 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
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'
|
|
2
6
|
|
|
3
7
|
module ResponseBank
|
|
4
8
|
class Middleware
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
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
|
|
8
11
|
|
|
9
12
|
REQUESTED_WITH = "HTTP_X_REQUESTED_WITH"
|
|
10
13
|
ACCEPT = "HTTP_ACCEPT"
|
|
11
14
|
USER_AGENT = "HTTP_USER_AGENT"
|
|
12
15
|
|
|
13
|
-
def initialize(app)
|
|
16
|
+
def initialize(app, brotli_splice_injector = nil)
|
|
14
17
|
@app = app
|
|
18
|
+
@brotli_splice_injector = brotli_splice_injector
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
def call(env)
|
|
18
22
|
env['cacheable.cache'] = false
|
|
23
|
+
install_brotli_splice_injector(env)
|
|
24
|
+
|
|
19
25
|
content_encoding = env['response_bank.server_cache_encoding'] = ResponseBank.check_encoding(env)
|
|
20
26
|
|
|
21
|
-
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)
|
|
22
35
|
|
|
23
36
|
if env['cacheable.cache']
|
|
24
|
-
if [200, 404, 301, 304].include?(status)
|
|
37
|
+
if [200, 404, 301, 304].include?(status) && !deferred_store
|
|
25
38
|
headers['ETag'] = %{"#{env['cacheable.key']}"}
|
|
26
39
|
end
|
|
27
40
|
|
|
28
|
-
if
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
body_string = +""
|
|
34
|
-
body.each { |part| body_string << part }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
body_compressed = nil
|
|
38
|
-
if body_string && body_string != ""
|
|
39
|
-
headers['Content-Encoding'] = content_encoding
|
|
40
|
-
env["cacheable.compression_level"] = ResponseBank.compression_level_for_request(env, headers)
|
|
41
|
-
time = ResponseBank.measure do
|
|
42
|
-
body_compressed = ResponseBank.compress(
|
|
43
|
-
body_string,
|
|
44
|
-
content_encoding,
|
|
45
|
-
compression_level: env["cacheable.compression_level"],
|
|
46
|
-
)
|
|
47
|
-
end
|
|
48
|
-
ResponseBank.log("Compression time: #{time}ms")
|
|
49
|
-
env["cacheable.compression_time"] = time
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
cached_headers = headers.slice(*CACHEABLE_HEADERS)
|
|
53
|
-
# Store result
|
|
54
|
-
cache_data = [status, cached_headers, body_compressed, timestamp, env["cacheable.compression_level"]]
|
|
55
|
-
|
|
56
|
-
ResponseBank.write_to_cache(env['cacheable.key']) do
|
|
57
|
-
payload = MessagePack.dump(cache_data)
|
|
58
|
-
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(
|
|
59
46
|
env,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
47
|
+
status: status,
|
|
48
|
+
headers: headers,
|
|
49
|
+
body: body_string,
|
|
50
|
+
timestamp: -> { timestamp },
|
|
51
|
+
content_encoding: content_encoding,
|
|
63
52
|
)
|
|
64
|
-
end
|
|
65
53
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
62
|
+
else
|
|
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}")
|
|
75
|
+
end
|
|
74
76
|
end
|
|
75
77
|
end
|
|
76
78
|
end
|
|
@@ -87,9 +89,21 @@ module ResponseBank
|
|
|
87
89
|
|
|
88
90
|
private
|
|
89
91
|
|
|
92
|
+
def install_brotli_splice_injector(env)
|
|
93
|
+
return unless @brotli_splice_injector
|
|
94
|
+
return if env.key?(ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY)
|
|
95
|
+
|
|
96
|
+
injector = if @brotli_splice_injector.respond_to?(:call)
|
|
97
|
+
@brotli_splice_injector.call(env)
|
|
98
|
+
else
|
|
99
|
+
@brotli_splice_injector
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
env[ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY] = injector if injector
|
|
103
|
+
end
|
|
104
|
+
|
|
90
105
|
def timestamp
|
|
91
106
|
Time.now.to_i
|
|
92
107
|
end
|
|
93
|
-
|
|
94
108
|
end
|
|
95
109
|
end
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'rails'
|
|
3
|
+
require 'active_support/ordered_options'
|
|
3
4
|
require 'response_bank/controller'
|
|
4
5
|
require 'response_bank/model_extensions'
|
|
5
6
|
|
|
6
7
|
module ResponseBank
|
|
7
8
|
class Railtie < ::Rails::Railtie
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
config.response_bank = ActiveSupport::OrderedOptions.new
|
|
10
|
+
config.response_bank.brotli_splice_injector = nil
|
|
11
|
+
|
|
12
|
+
initializer "cachable.configure_active_record" do |app|
|
|
13
|
+
app.config.middleware.insert_after(
|
|
14
|
+
Rack::Head,
|
|
15
|
+
ResponseBank::Middleware,
|
|
16
|
+
app.config.response_bank.brotli_splice_injector,
|
|
17
|
+
)
|
|
10
18
|
|
|
11
19
|
ActiveSupport.on_load(:action_controller) do
|
|
12
20
|
include ResponseBank::Controller
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'digest/md5'
|
|
3
|
+
require 'response_bank/brotli_splice_slot'
|
|
4
|
+
require 'response_bank/deferred_store'
|
|
3
5
|
|
|
4
6
|
module ResponseBank
|
|
5
7
|
class ResponseCacheHandler
|
|
@@ -82,18 +84,25 @@ module ResponseBank
|
|
|
82
84
|
end
|
|
83
85
|
|
|
84
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
|
|
85
96
|
# Etag
|
|
86
97
|
unless @skip_browser_cache
|
|
87
98
|
response = serve_from_browser_cache(entity_tag_hash, @env['HTTP_IF_NONE_MATCH'])
|
|
88
99
|
return response if response
|
|
89
100
|
end
|
|
90
101
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# Yield to the controller and mark for writing into cache.
|
|
96
|
-
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
|
|
97
106
|
end
|
|
98
107
|
|
|
99
108
|
def serve_from_browser_cache(entity_tag, if_none_match)
|
|
@@ -119,7 +128,7 @@ module ResponseBank
|
|
|
119
128
|
@env['cacheable.miss'] = false
|
|
120
129
|
@env['cacheable.store'] = 'server'
|
|
121
130
|
|
|
122
|
-
status, headers, body, timestamp, compression_level = hit
|
|
131
|
+
status, headers, body, timestamp, compression_level, metadata = hit
|
|
123
132
|
|
|
124
133
|
@env['cacheable.compression_level'] = compression_level
|
|
125
134
|
|
|
@@ -136,6 +145,7 @@ module ResponseBank
|
|
|
136
145
|
if ResponseBank.acquire_lock(match_entity_tag)
|
|
137
146
|
# execute if we can get the lock
|
|
138
147
|
@env['cacheable.locked'] = true
|
|
148
|
+
@env[ResponseBank::DeferredStore::LOCK_OWNED_ENV_KEY] = true
|
|
139
149
|
return
|
|
140
150
|
elsif stale_while_revalidate?(timestamp, cache_age_tolerance)
|
|
141
151
|
# cache is being regenerated, can we avoid piling on and use a stale version in the interim?
|
|
@@ -152,9 +162,14 @@ module ResponseBank
|
|
|
152
162
|
@headers.merge!(headers)
|
|
153
163
|
|
|
154
164
|
if @headers['Content-Encoding']
|
|
155
|
-
if
|
|
165
|
+
if @env['HTTP_ACCEPT_ENCODING'].to_s.include?(@headers['Content-Encoding'])
|
|
166
|
+
if @headers['Content-Encoding'] == 'br'
|
|
167
|
+
body = ResponseBank::BrotliSpliceSlot.replace_compressed_secret(@env, body, metadata)
|
|
168
|
+
end
|
|
169
|
+
else
|
|
156
170
|
ResponseBank.log("uncompressing payload for client as client doesn't require encoding")
|
|
157
171
|
body = ResponseBank.decompress(body, @headers['Content-Encoding'])
|
|
172
|
+
body = ResponseBank::BrotliSpliceSlot.replace_plain_body(@env, body, metadata)
|
|
158
173
|
@headers.delete('Content-Encoding')
|
|
159
174
|
end
|
|
160
175
|
else
|
|
@@ -180,9 +195,9 @@ module ResponseBank
|
|
|
180
195
|
|
|
181
196
|
# strictly speaking an unquoted etag is not valid, yet common
|
|
182
197
|
# to avoid unintended greedy matches in we check for naked entity then includes with quoted entity values
|
|
183
|
-
entity_tag = %{"#{entity_tag}"} unless entity_tag.
|
|
198
|
+
entity_tag = %{"#{entity_tag}"} unless entity_tag.start_with?('"')
|
|
184
199
|
|
|
185
|
-
if_none_match = %{"#{if_none_match}"} unless if_none_match.
|
|
200
|
+
if_none_match = %{"#{if_none_match}"} unless if_none_match.start_with?('"') || if_none_match.start_with?('W/"')
|
|
186
201
|
|
|
187
202
|
if_none_match == entity_tag || if_none_match.include?(entity_tag)
|
|
188
203
|
end
|
|
@@ -195,8 +210,10 @@ module ResponseBank
|
|
|
195
210
|
end
|
|
196
211
|
|
|
197
212
|
def refill_cache
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
unless @env['cacheable.locked']
|
|
214
|
+
acquired = ResponseBank.acquire_lock(entity_tag_hash)
|
|
215
|
+
@env[ResponseBank::DeferredStore::LOCK_OWNED_ENV_KEY] = !!acquired
|
|
216
|
+
end
|
|
200
217
|
@env['cacheable.locked'] = true
|
|
201
218
|
@env['cacheable.miss'] = true
|
|
202
219
|
|
|
@@ -204,5 +221,17 @@ module ResponseBank
|
|
|
204
221
|
|
|
205
222
|
@cache_miss_block.call
|
|
206
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
|
|
207
236
|
end
|
|
208
237
|
end
|
data/lib/response_bank.rb
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
require 'response_bank/brotli_splice_injector'
|
|
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'
|
|
2
7
|
require 'response_bank/middleware'
|
|
3
8
|
require 'response_bank/railtie' if defined?(Rails)
|
|
4
9
|
require 'response_bank/response_cache_handler'
|
|
@@ -7,14 +12,16 @@ require 'brotli'
|
|
|
7
12
|
require 'benchmark'
|
|
8
13
|
|
|
9
14
|
module ResponseBank
|
|
15
|
+
private_constant :CacheWriter
|
|
16
|
+
|
|
10
17
|
class << self
|
|
11
18
|
attr_accessor :cache_store
|
|
12
19
|
attr_writer :logger, :compression_level
|
|
13
20
|
|
|
14
21
|
DEFAULT_BROTLI_COMPRESSION_LEVEL = 7
|
|
15
22
|
|
|
16
|
-
DEFAULT_COMPRESSION_LEVEL = -> (
|
|
17
|
-
case
|
|
23
|
+
DEFAULT_COMPRESSION_LEVEL = -> (env, _headers) {
|
|
24
|
+
case env['response_bank.server_cache_encoding']
|
|
18
25
|
when 'br'
|
|
19
26
|
DEFAULT_BROTLI_COMPRESSION_LEVEL
|
|
20
27
|
when 'gzip'
|
|
@@ -38,6 +45,17 @@ module ResponseBank
|
|
|
38
45
|
raise NotImplementedError, "Override ResponseBank.acquire_lock in an initializer."
|
|
39
46
|
end
|
|
40
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
|
+
|
|
41
59
|
def write_to_cache(_key)
|
|
42
60
|
yield
|
|
43
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,34 @@ 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'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: brotli_splice
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - '='
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 0.1.1
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - '='
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 0.1.1
|
|
41
69
|
- !ruby/object:Gem::Dependency
|
|
42
70
|
name: minitest
|
|
43
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -118,7 +146,12 @@ files:
|
|
|
118
146
|
- LICENSE.txt
|
|
119
147
|
- README.md
|
|
120
148
|
- lib/response_bank.rb
|
|
149
|
+
- lib/response_bank/brotli_splice_injector.rb
|
|
150
|
+
- lib/response_bank/brotli_splice_slot.rb
|
|
151
|
+
- lib/response_bank/cache_policy.rb
|
|
152
|
+
- lib/response_bank/cache_writer.rb
|
|
121
153
|
- lib/response_bank/controller.rb
|
|
154
|
+
- lib/response_bank/deferred_store.rb
|
|
122
155
|
- lib/response_bank/middleware.rb
|
|
123
156
|
- lib/response_bank/model_extensions.rb
|
|
124
157
|
- lib/response_bank/railtie.rb
|
|
@@ -136,14 +169,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
136
169
|
requirements:
|
|
137
170
|
- - ">="
|
|
138
171
|
- !ruby/object:Gem::Version
|
|
139
|
-
version:
|
|
172
|
+
version: 3.1.0
|
|
140
173
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
141
174
|
requirements:
|
|
142
175
|
- - ">="
|
|
143
176
|
- !ruby/object:Gem::Version
|
|
144
177
|
version: '0'
|
|
145
178
|
requirements: []
|
|
146
|
-
rubygems_version:
|
|
179
|
+
rubygems_version: 4.0.16
|
|
147
180
|
specification_version: 4
|
|
148
181
|
summary: Simple response caching for Ruby applications
|
|
149
182
|
test_files: []
|