response_bank 1.3.8 → 1.4.0

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