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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9c672021efe766d0fc33677c57dd7b1b1c4dc83c1a88824f40919722111b99a
4
- data.tar.gz: f1215de9f225b7ea78d71cc7c815d7cff0811a737e3e9df4f686261e07339db7
3
+ metadata.gz: c3db28140576d28fa3faf6af84b571085208f7f40b977e615b102f038d60632a
4
+ data.tar.gz: ff991b1105a3c3b7827e5d7872ca55a648b692c6a67a67c144f5c458291adbe5
5
5
  SHA512:
6
- metadata.gz: 563abba82ed5b7e1308edcc406dfc842b4f7bfa1c7ae489d886f73a67737261690a8faeec48d0ff7e2458f57ec3142620da88777625362e4b2c874f8bd93873d
7
- data.tar.gz: bb6450674d14ba9aa4b9e9b35c54ebc740c55903ae0f3b19ead162c6764948c37bb69029e903ad75886dc7de40f1628e81c0af5ba411a7a015ff600d91bdaf7a
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
- METADATA_KEY => {
57
- 'version' => METADATA_VERSION,
58
- 'slots' => [metadata_slot],
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
- # Limit the cached headers
7
- # TODO: Make this lowercase/case-insentitive as per rfc2616 §4.2
8
- CACHEABLE_HEADERS = ["Location", "Content-Type", "ETag", "Content-Encoding", "Last-Modified", "Cache-Control", "Expires", "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 = @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)
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 [200, 404, 301].include?(status) && env['cacheable.miss']
33
- # Flatten down the result so that it can be stored to memcached.
34
- if body.is_a?(String)
35
- body_string = body
36
- else
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
- env['cacheable.unversioned-key'],
82
- payload,
83
- 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,
84
52
  )
85
- end
86
53
 
87
- # since we had to generate the compressed version already we may
88
- # as well serve it if the client wants it
89
- if body_compressed
90
- if env['HTTP_ACCEPT_ENCODING'].to_s.include?(content_encoding)
91
- if content_encoding == 'br'
92
- body = [ResponseBank::BrotliSpliceSlot.replace_compressed_secret(env, body_compressed, metadata)]
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
- body = [body_compressed]
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
- response = serve_from_cache(cache_key_hash, @serve_unversioned ? "*" : entity_tag_hash, @cache_age_tolerance)
93
- return response if response
94
-
95
- # No cache hit; this request cannot be handled from cache.
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
- # non cache hits do not yet have the lock
205
- 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
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
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module ResponseBank
3
- VERSION = "1.3.8"
3
+ VERSION = "1.5.0"
4
4
  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 = -> (_env, headers) {
19
- case headers['Content-Encoding']
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.3.8
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.1.1
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.1.1
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.14
179
+ rubygems_version: 4.0.19
163
180
  specification_version: 4
164
181
  summary: Simple response caching for Ruby applications
165
182
  test_files: []