response_bank 1.3.8 → 1.5.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 +69 -0
- data/lib/response_bank/brotli_splice_slot.rb +31 -15
- data/lib/response_bank/cache_policy.rb +6 -0
- data/lib/response_bank/cache_writer.rb +165 -0
- data/lib/response_bank/deferred_store.rb +188 -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 +21 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3db28140576d28fa3faf6af84b571085208f7f40b977e615b102f038d60632a
|
|
4
|
+
data.tar.gz: ff991b1105a3c3b7827e5d7872ca55a648b692c6a67a67c144f5c458291adbe5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: face725382f16dc7ae054efd68de804eb303ff566bbf6758aa88d09922a2d8747e78fc9b9b2a9d109d508a0af9bcea28dc919d7f2b70954661c349f5feaaad2a
|
|
7
|
+
data.tar.gz: b9101b86d39ddc5ea9a427326cfd58834bd5d3015509d0411007f286ad34cd00f1113c43f6d20d1d01a2cc317340d905dc9bfb1b24bb2e2df8c6509ca897b00e
|
data/README.md
CHANGED
|
@@ -123,6 +123,52 @@ 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
|
+
A caller that has already produced a Brotli representation can complete the fill without a second compression pass:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
deferred_store.complete_spliced(
|
|
152
|
+
headers: cache_headers,
|
|
153
|
+
body: compressed_body,
|
|
154
|
+
compression_level: 5,
|
|
155
|
+
slot: {
|
|
156
|
+
name: 'shopify_y',
|
|
157
|
+
compressed_offset: slot_offset,
|
|
158
|
+
replacement_length: replacement_length,
|
|
159
|
+
html_placeholder_offset: html_placeholder_offset,
|
|
160
|
+
html_placeholder_length: html_placeholder_length,
|
|
161
|
+
context_suffix: "\r\n",
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The body must be a complete, non-empty Brotli stream in the server cache encoding. It must already be safe for shared caching: if the live response contained client-specific slot bytes, replace them with the neutral placeholder before calling `complete_spliced`. ResponseBank builds versioned cache metadata from the slot descriptor. Omit `slot` when the Brotli stream has no replaceable content.
|
|
167
|
+
|
|
168
|
+
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.
|
|
169
|
+
|
|
170
|
+
`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.
|
|
171
|
+
|
|
126
172
|
## Brotli Splice Slots
|
|
127
173
|
|
|
128
174
|
Applications that need per-request replacement inside cached Brotli HTML responses can pass an injector builder to `ResponseBank::Middleware`:
|
|
@@ -241,6 +287,29 @@ Advanced integrations can still install the per-request injector directly in the
|
|
|
241
287
|
env[ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY] = injector
|
|
242
288
|
```
|
|
243
289
|
|
|
290
|
+
## Exception Handling
|
|
291
|
+
|
|
292
|
+
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:
|
|
293
|
+
|
|
294
|
+
1. **On cache read failures**: Fall back to rendering the page normally (as if it was a cache miss).
|
|
295
|
+
2. **On cache write failures**: Still serve the successfully rendered page to the user, but log the cache write failure.
|
|
296
|
+
|
|
297
|
+
This ensures that issues with cache stores (Redis/Memcached down), serialization errors, or compression/decompression failures don't cause 500 errors for your users.
|
|
298
|
+
|
|
299
|
+
### Custom Exception Handlers
|
|
300
|
+
|
|
301
|
+
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.):
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
# In an initializer or middleware
|
|
305
|
+
class MyMiddleware
|
|
306
|
+
def call(env)
|
|
307
|
+
env['response_bank.on_exception'] = ->(e) { Bugsnag.notify(e) }
|
|
308
|
+
@app.call(env)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
```
|
|
312
|
+
|
|
244
313
|
## License
|
|
245
314
|
|
|
246
315
|
ResponseBank is released under the [MIT License](LICENSE.txt).
|
|
@@ -40,24 +40,17 @@ module ResponseBank
|
|
|
40
40
|
begin
|
|
41
41
|
result = BrotliSplice.encode(prepared_body, html_offset, html_length, quality: compression_level)
|
|
42
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
43
|
EncodedBody.new(
|
|
53
44
|
body: prepared_body,
|
|
54
45
|
compressed_body: result[:data],
|
|
55
|
-
metadata:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
46
|
+
metadata: metadata_for(
|
|
47
|
+
name: slot_name,
|
|
48
|
+
compressed_offset: result[:secret_offset],
|
|
49
|
+
replacement_length: result[:secret_length],
|
|
50
|
+
html_placeholder_offset: html_offset,
|
|
51
|
+
html_placeholder_length: html_length,
|
|
52
|
+
context_suffix: result[:context_suffix],
|
|
53
|
+
),
|
|
61
54
|
)
|
|
62
55
|
rescue BrotliSplice::Error, ArgumentError => error
|
|
63
56
|
ResponseBank.log("BrotliSplice encode skipped: #{error.class}")
|
|
@@ -65,6 +58,29 @@ module ResponseBank
|
|
|
65
58
|
end
|
|
66
59
|
end
|
|
67
60
|
|
|
61
|
+
def metadata_for(
|
|
62
|
+
name:,
|
|
63
|
+
compressed_offset:,
|
|
64
|
+
replacement_length:,
|
|
65
|
+
html_placeholder_offset:,
|
|
66
|
+
html_placeholder_length:,
|
|
67
|
+
context_suffix:
|
|
68
|
+
)
|
|
69
|
+
{
|
|
70
|
+
METADATA_KEY => {
|
|
71
|
+
'version' => METADATA_VERSION,
|
|
72
|
+
'slots' => [{
|
|
73
|
+
'name' => name.to_s,
|
|
74
|
+
'compressed_offset' => compressed_offset,
|
|
75
|
+
'replacement_length' => replacement_length,
|
|
76
|
+
'html_placeholder_offset' => html_placeholder_offset,
|
|
77
|
+
'html_placeholder_length' => html_placeholder_length,
|
|
78
|
+
'context_suffix' => context_suffix,
|
|
79
|
+
}],
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
68
84
|
def replace_compressed_secret(env, body, metadata)
|
|
69
85
|
injector = env[INJECTOR_ENV_KEY]
|
|
70
86
|
slots = metadata_slots(metadata)
|
|
@@ -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,165 @@
|
|
|
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
|
+
representation_headers = representation_headers(env, headers)
|
|
34
|
+
stored = prepare_body(env, representation_headers, body, content_encoding)
|
|
35
|
+
persist(
|
|
36
|
+
env,
|
|
37
|
+
status: status,
|
|
38
|
+
representation_headers: representation_headers,
|
|
39
|
+
stored: stored,
|
|
40
|
+
timestamp: timestamp,
|
|
41
|
+
content_encoding: content_encoding,
|
|
42
|
+
before_write: before_write,
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def store_spliced(
|
|
47
|
+
env,
|
|
48
|
+
status:,
|
|
49
|
+
headers:,
|
|
50
|
+
body:,
|
|
51
|
+
compression_level:,
|
|
52
|
+
slot: nil,
|
|
53
|
+
timestamp:,
|
|
54
|
+
before_write: nil
|
|
55
|
+
)
|
|
56
|
+
validate_spliced_body!(env, body)
|
|
57
|
+
metadata = slot && BrotliSpliceSlot.metadata_for(**slot)
|
|
58
|
+
env['cacheable.compression_level'] = compression_level
|
|
59
|
+
persist(
|
|
60
|
+
env,
|
|
61
|
+
status: status,
|
|
62
|
+
representation_headers: representation_headers(env, headers),
|
|
63
|
+
stored: Stored.new(body: nil, compressed_body: body, metadata: metadata),
|
|
64
|
+
timestamp: timestamp,
|
|
65
|
+
content_encoding: 'br',
|
|
66
|
+
before_write: before_write,
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def representation_headers(env, headers)
|
|
73
|
+
headers.slice(*ResponseBank::CACHEABLE_HEADERS).tap do |cached_headers|
|
|
74
|
+
cached_headers['ETag'] = %{"#{env.fetch('cacheable.key')}"}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def persist(env, status:, representation_headers:, stored:, timestamp:, content_encoding:, before_write:)
|
|
79
|
+
generated_at = timestamp.respond_to?(:call) ? timestamp.call : timestamp
|
|
80
|
+
data = cache_data(status, representation_headers, stored, env, generated_at, content_encoding)
|
|
81
|
+
|
|
82
|
+
before_write&.call
|
|
83
|
+
ResponseBank.write_to_cache(env.fetch('cacheable.key')) do
|
|
84
|
+
payload = MessagePack.dump(data)
|
|
85
|
+
ResponseBank.write_to_backing_cache_store(
|
|
86
|
+
env,
|
|
87
|
+
env.fetch('cacheable.unversioned-key'),
|
|
88
|
+
payload,
|
|
89
|
+
expires_in: env['cacheable.versioned-cache-expiry'],
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
stored
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_spliced_body!(env, body)
|
|
97
|
+
unless body.is_a?(String) && !body.empty?
|
|
98
|
+
raise ArgumentError, 'spliced body must be a non-empty String'
|
|
99
|
+
end
|
|
100
|
+
return if env.fetch('response_bank.server_cache_encoding') == 'br'
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, 'spliced bodies require br server cache encoding'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def prepare_body(env, headers, body, content_encoding)
|
|
106
|
+
body = flatten(body)
|
|
107
|
+
return Stored.new(body: body) if body.empty?
|
|
108
|
+
|
|
109
|
+
representation_headers = headers.merge('Content-Encoding' => content_encoding)
|
|
110
|
+
compression_level = ResponseBank.compression_level_for_request(env, representation_headers)
|
|
111
|
+
env['cacheable.compression_level'] = compression_level
|
|
112
|
+
body_compressed = nil
|
|
113
|
+
metadata = nil
|
|
114
|
+
time = ResponseBank.measure do
|
|
115
|
+
encoded_body = encode_spliced_body(
|
|
116
|
+
env,
|
|
117
|
+
body,
|
|
118
|
+
representation_headers,
|
|
119
|
+
content_encoding,
|
|
120
|
+
compression_level,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if encoded_body
|
|
124
|
+
body = encoded_body.body
|
|
125
|
+
body_compressed = encoded_body.compressed_body
|
|
126
|
+
metadata = encoded_body.metadata
|
|
127
|
+
else
|
|
128
|
+
body_compressed = ResponseBank.compress(
|
|
129
|
+
body,
|
|
130
|
+
content_encoding,
|
|
131
|
+
compression_level: compression_level,
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
ResponseBank.log("Compression time: #{time}ms")
|
|
136
|
+
env['cacheable.compression_time'] = time
|
|
137
|
+
|
|
138
|
+
Stored.new(body: body, compressed_body: body_compressed, metadata: metadata)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def encode_spliced_body(env, body, headers, content_encoding, compression_level)
|
|
142
|
+
return unless content_encoding == 'br'
|
|
143
|
+
|
|
144
|
+
ResponseBank::BrotliSpliceSlot.encode_body(
|
|
145
|
+
env,
|
|
146
|
+
body,
|
|
147
|
+
headers,
|
|
148
|
+
compression_level: compression_level,
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def cache_data(status, representation_headers, stored, env, timestamp, content_encoding)
|
|
153
|
+
if stored.compressed_body
|
|
154
|
+
representation_headers['Content-Encoding'] = content_encoding
|
|
155
|
+
else
|
|
156
|
+
representation_headers.delete('Content-Encoding')
|
|
157
|
+
end
|
|
158
|
+
cached_headers = representation_headers.slice(*ResponseBank::CACHEABLE_HEADERS)
|
|
159
|
+
data = [status, cached_headers, stored.compressed_body, timestamp, env['cacheable.compression_level']]
|
|
160
|
+
data << stored.metadata if stored.metadata
|
|
161
|
+
data
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
complete_with(headers) do |status, cached_headers, before_write|
|
|
64
|
+
CacheWriter.store(
|
|
65
|
+
@env,
|
|
66
|
+
status: status,
|
|
67
|
+
headers: cached_headers,
|
|
68
|
+
body: body,
|
|
69
|
+
timestamp: @timestamp,
|
|
70
|
+
before_write: before_write,
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# `body` must be a complete Brotli stream that is already safe for shared
|
|
76
|
+
# caching. If present, `slot` describes its neutral replacement slot.
|
|
77
|
+
def complete_spliced(body:, compression_level:, slot: nil, headers: nil)
|
|
78
|
+
complete_with(headers) do |status, cached_headers, before_write|
|
|
79
|
+
CacheWriter.store_spliced(
|
|
80
|
+
@env,
|
|
81
|
+
status: status,
|
|
82
|
+
headers: cached_headers,
|
|
83
|
+
body: body,
|
|
84
|
+
compression_level: compression_level,
|
|
85
|
+
slot: slot,
|
|
86
|
+
timestamp: @timestamp,
|
|
87
|
+
before_write: before_write,
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def abort
|
|
93
|
+
transitioned, release_lock = @mutex.synchronize do
|
|
94
|
+
if TERMINAL_STATES.include?(@state)
|
|
95
|
+
[false, false]
|
|
96
|
+
else
|
|
97
|
+
@state = :aborted
|
|
98
|
+
[true, @owns_lock]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
release_owned_lock if release_lock
|
|
103
|
+
transitioned
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def complete_with(headers)
|
|
109
|
+
status, cached_headers, release_lock = prepare_completion(headers)
|
|
110
|
+
|
|
111
|
+
if release_lock
|
|
112
|
+
release_owned_lock
|
|
113
|
+
return false
|
|
114
|
+
end
|
|
115
|
+
return false unless status
|
|
116
|
+
|
|
117
|
+
write_started = false
|
|
118
|
+
completed = false
|
|
119
|
+
begin
|
|
120
|
+
yield(status, cached_headers, -> { write_started = true })
|
|
121
|
+
completed = true
|
|
122
|
+
ensure
|
|
123
|
+
@mutex.synchronize { @state = :consumed }
|
|
124
|
+
@env['cacheable.locked'] = false if @owns_lock
|
|
125
|
+
release_owned_lock if @owns_lock && !completed && !write_started
|
|
126
|
+
end
|
|
127
|
+
true
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def arm(status:, headers:)
|
|
131
|
+
@mutex.synchronize do
|
|
132
|
+
if @state != :aborted
|
|
133
|
+
raise StateError, "cannot arm a deferred store in the #{@state} state" unless @state == :requested
|
|
134
|
+
|
|
135
|
+
@status = status
|
|
136
|
+
@headers = headers.slice(*ResponseBank::CACHEABLE_HEADERS)
|
|
137
|
+
@eligible = @owns_lock && cache_miss? && status_cacheable?(status)
|
|
138
|
+
@state = :armed
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
self
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def prepare_completion(headers)
|
|
146
|
+
@mutex.synchronize do
|
|
147
|
+
raise StateError, 'the deferred store has not been armed by the middleware' if @state == :requested
|
|
148
|
+
return [nil, nil, false] if @state == :aborted
|
|
149
|
+
raise StateError, "cannot complete a deferred store in the #{@state} state" unless @state == :armed
|
|
150
|
+
|
|
151
|
+
cached_headers = (headers || @headers).slice(*ResponseBank::CACHEABLE_HEADERS)
|
|
152
|
+
if completion_eligible?(cached_headers)
|
|
153
|
+
@state = :completing
|
|
154
|
+
[@status, cached_headers, false]
|
|
155
|
+
else
|
|
156
|
+
@state = :aborted
|
|
157
|
+
[nil, nil, @owns_lock]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def completion_eligible?(headers)
|
|
163
|
+
@eligible && cache_miss? && cache_control_allows_storage?(headers)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def cache_miss?
|
|
167
|
+
@env['cacheable.cache'] && @env['cacheable.miss']
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def status_cacheable?(status)
|
|
171
|
+
ResponseBank::CACHEABLE_STATUSES.include?(status)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def cache_control_allows_storage?(headers)
|
|
175
|
+
value = headers['Cache-Control']
|
|
176
|
+
return true unless value
|
|
177
|
+
|
|
178
|
+
directives = value.split(',').map { |directive| directive.strip.downcase.split('=', 2).first }
|
|
179
|
+
(directives & PRIVATE_CACHE_DIRECTIVES).empty?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def release_owned_lock
|
|
183
|
+
ResponseBank.release_lock(@cache_key)
|
|
184
|
+
ensure
|
|
185
|
+
@env['cacheable.locked'] = false
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tobias Lütke
|
|
@@ -38,20 +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'
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: brotli_splice
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
|
44
58
|
requirements:
|
|
45
59
|
- - '='
|
|
46
60
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: 0.
|
|
61
|
+
version: 0.2.0
|
|
48
62
|
type: :development
|
|
49
63
|
prerelease: false
|
|
50
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
65
|
requirements:
|
|
52
66
|
- - '='
|
|
53
67
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: 0.
|
|
68
|
+
version: 0.2.0
|
|
55
69
|
- !ruby/object:Gem::Dependency
|
|
56
70
|
name: minitest
|
|
57
71
|
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.19
|
|
163
180
|
specification_version: 4
|
|
164
181
|
summary: Simple response caching for Ruby applications
|
|
165
182
|
test_files: []
|