response_bank 1.4.0 → 1.6.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: f9667d810b487f5db154d43ba2e7be769ab999d0d721b881a5c558e97ae55ceb
4
- data.tar.gz: 744ec28ba5bdd1e5ae368e215d0ca6e86d0ce0d8910c49cd13b5835fe4283e9b
3
+ metadata.gz: 4049027dc5a512bb6f1225b179ac82989b995e8e77f0b384fce0154c58cd76c0
4
+ data.tar.gz: 0351324db2bb6a5d00d2585d7e2fb52d5f1f17860f19ec56e23bc848d411ce0f
5
5
  SHA512:
6
- metadata.gz: ec9cb3918c4ff1d9bf6b5d1e18c18eb6bae8f0a49cdc8f15aff494c7d43fc2b3e1bcd38078770d75cfb06f4517f08515b67a8429877d815952576703461eb929
7
- data.tar.gz: 9d815e56c3d9986367847c1f2f730c16fa72ab989dcedcf379cd849dd00a436076c7a08b92a94e0123ded75058d628d17b2a6dcab26fc6598a7057140daabfb7
6
+ metadata.gz: 278e15de6e89c0a006c13da52ff42a326be704050ef905d8ad7c0541a8444359253923a9331747440f72fe2855ac7addf3af3b917c75c5a2775c052618aa62fb
7
+ data.tar.gz: 9ec95ddb14b3a4d6763dd7ddd0e40b9f5367a3943baad464890b64723747b849627cbb513e1d3b982640563f3c73e1f18d8a437470e6303853369c07bf9b8154
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 3.1.0+
14
+ * Ruby 3.3.0+
15
15
  * Rails 6.0.0+
16
16
 
17
17
  ## Usage
@@ -145,10 +145,48 @@ The middleware arms the handle after the application returns the Rack tuple. It
145
145
 
146
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
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
+
148
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.
149
169
 
150
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.
151
171
 
172
+ ## Cache Entry Metadata
173
+
174
+ Applications can store a Hash inside a cache entry by setting
175
+ `env['cacheable.metadata']` before the response is stored:
176
+
177
+ ```ruby
178
+ env['cacheable.metadata'] = { 'variant' => 'b' }
179
+ ```
180
+
181
+ On a server cache hit, `env['cacheable.metadata']` holds the Hash stored with the
182
+ served entry, stale or fresh; the key is removed when the entry has none. The Hash
183
+ is stored once and shared by every request served from the entry, so it must not
184
+ contain anything visitor-specific. It is serialized with MessagePack (String keys on
185
+ read) and nested under `ResponseBank::APP_METADATA_KEY`, so it cannot collide with
186
+ ResponseBank's own slots. Keep it to plain MessagePack types: a Hash that does not
187
+ survive a MessagePack round-trip, because a value cannot be serialized or the nesting
188
+ is too deep for the unpacker, is logged and dropped, and the response is cached without it.
189
+
152
190
  ## Brotli Splice Slots
153
191
 
154
192
  Applications that need per-request replacement inside cached Brotli HTML responses can pass an injector builder to `ResponseBank::Middleware`:
@@ -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)
@@ -3,4 +3,11 @@
3
3
  module ResponseBank
4
4
  CACHEABLE_HEADERS = ["Location", "Content-Type", "ETag", "Content-Encoding", "Last-Modified", "Cache-Control", "Expires", "Link", "Surrogate-Keys", "Cache-Tags", "Speculation-Rules"].freeze
5
5
  CACHEABLE_STATUSES = [200, 404, 301].freeze
6
+
7
+ # Application metadata stored inside the cache entry: set `env[METADATA_ENV_KEY]`
8
+ # (a Hash) before the response is stored; on a server cache hit it holds the Hash
9
+ # stored with the served entry.
10
+ METADATA_ENV_KEY = 'cacheable.metadata'
11
+ # Keeps application metadata apart from ResponseBank's own slots.
12
+ APP_METADATA_KEY = 'app'
6
13
  end
@@ -30,20 +30,61 @@ module ResponseBank
30
30
  content_encoding: env.fetch('response_bank.server_cache_encoding'),
31
31
  before_write: nil
32
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}"}
33
+ representation_headers = representation_headers(env, headers)
37
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:)
38
79
  generated_at = timestamp.respond_to?(:call) ? timestamp.call : timestamp
39
80
  data = cache_data(status, representation_headers, stored, env, generated_at, content_encoding)
40
81
 
41
82
  before_write&.call
42
- ResponseBank.write_to_cache(cache_key) do
83
+ ResponseBank.write_to_cache(env.fetch('cacheable.key')) do
43
84
  payload = MessagePack.dump(data)
44
85
  ResponseBank.write_to_backing_cache_store(
45
86
  env,
46
- unversioned_key,
87
+ env.fetch('cacheable.unversioned-key'),
47
88
  payload,
48
89
  expires_in: env['cacheable.versioned-cache-expiry'],
49
90
  )
@@ -52,7 +93,14 @@ module ResponseBank
52
93
  stored
53
94
  end
54
95
 
55
- private
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
56
104
 
57
105
  def prepare_body(env, headers, body, content_encoding)
58
106
  body = flatten(body)
@@ -109,9 +157,34 @@ module ResponseBank
109
157
  end
110
158
  cached_headers = representation_headers.slice(*ResponseBank::CACHEABLE_HEADERS)
111
159
  data = [status, cached_headers, stored.compressed_body, timestamp, env['cacheable.compression_level']]
112
- data << stored.metadata if stored.metadata
160
+ metadata = entry_metadata(env, stored.metadata)
161
+ data << metadata if metadata
113
162
  data
114
163
  end
164
+
165
+ # Application metadata never fails the write, nor the reads that follow: anything
166
+ # that is not a Hash, or that does not round-trip through MessagePack, is logged
167
+ # and left out of the entry.
168
+ def entry_metadata(env, metadata)
169
+ app_metadata = env[ResponseBank::METADATA_ENV_KEY]
170
+ return metadata if app_metadata.nil?
171
+ unless app_metadata.is_a?(Hash)
172
+ ResponseBank.log("Ignoring #{ResponseBank::METADATA_ENV_KEY}: expected a Hash, got #{app_metadata.class}")
173
+ return metadata
174
+ end
175
+
176
+ merged = (metadata || {}).merge(ResponseBank::APP_METADATA_KEY => app_metadata)
177
+ begin
178
+ # Loading catches what dumping accepts but the reader rejects, such as nesting
179
+ # past the unpacker's stack; the array puts the Hash at its depth in the entry.
180
+ MessagePack.load(MessagePack.dump([merged]))
181
+ rescue StandardError => error
182
+ ResponseBank.log("Ignoring #{ResponseBank::METADATA_ENV_KEY}: #{error.class} - #{error.message}")
183
+ return metadata
184
+ end
185
+
186
+ merged
187
+ end
115
188
  end
116
189
  end
117
190
  end
@@ -60,32 +60,33 @@ module ResponseBank
60
60
  # `body` and `headers` must describe the complete shared cache representation,
61
61
  # not a partial response or bytes personalized for the live client.
62
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
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
+ )
68
72
  end
69
- return false unless status
73
+ end
70
74
 
71
- write_started = false
72
- completed = false
73
- begin
74
- CacheWriter.store(
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(
75
80
  @env,
76
81
  status: status,
77
82
  headers: cached_headers,
78
83
  body: body,
84
+ compression_level: compression_level,
85
+ slot: slot,
79
86
  timestamp: @timestamp,
80
- before_write: -> { write_started = true },
87
+ before_write: before_write,
81
88
  )
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
89
  end
88
- true
89
90
  end
90
91
 
91
92
  def abort
@@ -104,6 +105,28 @@ module ResponseBank
104
105
 
105
106
  private
106
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
+
107
130
  def arm(status:, headers:)
108
131
  @mutex.synchronize do
109
132
  if @state != :aborted
@@ -176,6 +176,14 @@ module ResponseBank
176
176
  ResponseBank.log("Cache hit, but missing content-encoding in the cache value headers, maybe because of 301 or 404 response or empty body string")
177
177
  end
178
178
 
179
+ # After decompression and splicing, so a refill never inherits the rejected
180
+ # entry's metadata; cleared when the served entry has none.
181
+ if metadata&.key?(ResponseBank::APP_METADATA_KEY)
182
+ @env[ResponseBank::METADATA_ENV_KEY] = metadata[ResponseBank::APP_METADATA_KEY]
183
+ else
184
+ @env.delete(ResponseBank::METADATA_ENV_KEY)
185
+ end
186
+
179
187
  [status, @headers, [body]]
180
188
 
181
189
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module ResponseBank
3
- VERSION = "1.4.0"
3
+ VERSION = "1.6.0"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: response_bank
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tobias Lütke
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - '='
60
60
  - !ruby/object:Gem::Version
61
- version: 0.1.1
61
+ version: 0.2.0
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - '='
67
67
  - !ruby/object:Gem::Version
68
- version: 0.1.1
68
+ version: 0.2.0
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: minitest
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -169,14 +169,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
169
169
  requirements:
170
170
  - - ">="
171
171
  - !ruby/object:Gem::Version
172
- version: 3.1.0
172
+ version: 3.3.0
173
173
  required_rubygems_version: !ruby/object:Gem::Requirement
174
174
  requirements:
175
175
  - - ">="
176
176
  - !ruby/object:Gem::Version
177
177
  version: '0'
178
178
  requirements: []
179
- rubygems_version: 4.0.16
179
+ rubygems_version: 4.0.21
180
180
  specification_version: 4
181
181
  summary: Simple response caching for Ruby applications
182
182
  test_files: []