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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a5c7a799e1272fffad4878273bf154c09f809059944dbf47bf6c6c83d1b4acb
4
- data.tar.gz: ff7a669443427dcee35c7d2a590258fc0ac0591bd156c1316a42198cacc4bd0e
3
+ metadata.gz: f9667d810b487f5db154d43ba2e7be769ab999d0d721b881a5c558e97ae55ceb
4
+ data.tar.gz: 744ec28ba5bdd1e5ae368e215d0ca6e86d0ce0d8910c49cd13b5835fe4283e9b
5
5
  SHA512:
6
- metadata.gz: 5ddaeeca4364360ce6f2392d5b53d2f34421e44a62f1184d594add38dc45ebf23f72184bc9aa097667404c83d6b64c450b91b1dd25726a307ba41149b8a9509d
7
- data.tar.gz: 4bfc7ebc29beab0c67f72e24e635d9fa2a0ca59a06fd92027d00172acc31a1974288adee1a97baf005e2ae64751a49e205a7005c70881f27e44b03a6827bf66a
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 2.7.0+
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
- # Limit the cached headers
6
- # TODO: Make this lowercase/case-insentitive as per rfc2616 §4.2
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 = @app.call(env)
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 [200, 404, 301].include?(status) && env['cacheable.miss']
29
- # Flatten down the result so that it can be stored to memcached.
30
- if body.is_a?(String)
31
- body_string = body
32
- else
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
- env['cacheable.unversioned-key'],
61
- payload,
62
- expires_in: env['cacheable.versioned-cache-expiry'],
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
- # since we had to generate the compressed version already we may
67
- # as well serve it if the client wants it
68
- if body_compressed
69
- if env['HTTP_ACCEPT_ENCODING'].to_s.include?(content_encoding)
70
- body = [body_compressed]
71
- else
72
- # Remove content-encoding header for response with compressed content
73
- headers.delete('Content-Encoding')
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
- initializer "cachable.configure_active_record" do |config|
9
- config.middleware.insert_after(Rack::Head, ResponseBank::Middleware)
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
- response = serve_from_cache(cache_key_hash, @serve_unversioned ? "*" : entity_tag_hash, @cache_age_tolerance)
92
- return response if response
93
-
94
- # No cache hit; this request cannot be handled from cache.
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 !@env['HTTP_ACCEPT_ENCODING'].to_s.include?(@headers['Content-Encoding'])
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.starts_with?('"')
198
+ entity_tag = %{"#{entity_tag}"} unless entity_tag.start_with?('"')
184
199
 
185
- if_none_match = %{"#{if_none_match}"} unless if_none_match.starts_with?('"') || if_none_match.starts_with?('W/"')
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
- # non cache hits do not yet have the lock
199
- ResponseBank.acquire_lock(entity_tag_hash) unless @env['cacheable.locked']
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
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module ResponseBank
3
- VERSION = "1.3.7"
3
+ VERSION = "1.4.0"
4
4
  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 = -> (_env, headers) {
17
- case headers['Content-Encoding']
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.3.7
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: 2.7.0
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: 3.7.2
179
+ rubygems_version: 4.0.16
147
180
  specification_version: 4
148
181
  summary: Simple response caching for Ruby applications
149
182
  test_files: []