response_bank 1.4.0 → 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: f9667d810b487f5db154d43ba2e7be769ab999d0d721b881a5c558e97ae55ceb
4
- data.tar.gz: 744ec28ba5bdd1e5ae368e215d0ca6e86d0ce0d8910c49cd13b5835fe4283e9b
3
+ metadata.gz: c3db28140576d28fa3faf6af84b571085208f7f40b977e615b102f038d60632a
4
+ data.tar.gz: ff991b1105a3c3b7827e5d7872ca55a648b692c6a67a67c144f5c458291adbe5
5
5
  SHA512:
6
- metadata.gz: ec9cb3918c4ff1d9bf6b5d1e18c18eb6bae8f0a49cdc8f15aff494c7d43fc2b3e1bcd38078770d75cfb06f4517f08515b67a8429877d815952576703461eb929
7
- data.tar.gz: 9d815e56c3d9986367847c1f2f730c16fa72ab989dcedcf379cd849dd00a436076c7a08b92a94e0123ded75058d628d17b2a6dcab26fc6598a7057140daabfb7
6
+ metadata.gz: face725382f16dc7ae054efd68de804eb303ff566bbf6758aa88d09922a2d8747e78fc9b9b2a9d109d508a0af9bcea28dc919d7f2b70954661c349f5feaaad2a
7
+ data.tar.gz: b9101b86d39ddc5ea9a427326cfd58834bd5d3015509d0411007f286ad34cd00f1113c43f6d20d1d01a2cc317340d905dc9bfb1b24bb2e2df8c6509ca897b00e
data/README.md CHANGED
@@ -145,6 +145,26 @@ 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.
@@ -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)
@@ -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)
@@ -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
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module ResponseBank
3
- VERSION = "1.4.0"
3
+ VERSION = "1.5.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.5.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
@@ -176,7 +176,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
176
176
  - !ruby/object:Gem::Version
177
177
  version: '0'
178
178
  requirements: []
179
- rubygems_version: 4.0.16
179
+ rubygems_version: 4.0.19
180
180
  specification_version: 4
181
181
  summary: Simple response caching for Ruby applications
182
182
  test_files: []