Skip to content

Commit 708c25d

Browse files
authored
Improve table resizing (#19)
1 parent 46fece8 commit 708c25d

File tree

4 files changed

+186
-38
lines changed

4 files changed

+186
-38
lines changed

lib/hpax.ex

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,20 @@ defmodule HPAX do
8282
end
8383

8484
@doc """
85-
Resizes the given table to the given size.
85+
Resizes the given table to the given maximum size.
86+
87+
This is intended for use where the overlying protocol has signaled a change to the table's
88+
maximum size, such as when an HTTP/2 `SETTINGS` frame is received.
89+
90+
If the indicated size is less than the table's current size, entries
91+
will be evicted as needed to fit within the specified size, and the table's
92+
maximum size will be decreased to the specified value. A flag will also be
93+
set which will enqueue a "dynamic table size update" command to be prefixed
94+
to the next block encoded with this table, per
95+
[RFC9113§4.3.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-4.3.1).
96+
97+
If the indicated size is greater than or equal to the table's current max size, no entries are evicted
98+
and the table's maximum size changes to the specified value.
8699
87100
## Examples
88101
@@ -91,7 +104,7 @@ defmodule HPAX do
91104
92105
"""
93106
@spec resize(table(), non_neg_integer()) :: table()
94-
defdelegate resize(table, new_size), to: Table
107+
defdelegate resize(table, new_max_size), to: Table
95108

96109
@doc """
97110
Decodes a header block fragment (HBF) through a given table.
@@ -114,12 +127,12 @@ defmodule HPAX do
114127
# Dynamic resizes must occur only at the start of a block
115128
# https://datatracker.ietf.org/doc/html/rfc7541#section-4.2
116129
def decode(<<0b001::3, rest::bitstring>>, %Table{} = table) do
117-
{new_size, rest} = decode_integer(rest, 5)
130+
{new_max_size, rest} = decode_integer(rest, 5)
118131

119-
# Dynamic resizes must be less than max table size
132+
# Dynamic resizes must be less than protocol max table size
120133
# https://datatracker.ietf.org/doc/html/rfc7541#section-6.3
121-
if new_size <= table.max_table_size do
122-
decode(rest, Table.resize(table, new_size))
134+
if new_max_size <= table.protocol_max_table_size do
135+
decode(rest, Table.dynamic_resize(table, new_max_size))
123136
else
124137
{:error, :protocol_error}
125138
end
@@ -149,7 +162,9 @@ defmodule HPAX do
149162
when header: {action, header_name(), header_value()},
150163
action: :store | :store_name | :no_store | :never_store
151164
def encode(headers, %Table{} = table) when is_list(headers) do
152-
encode_headers(headers, table, _acc = [])
165+
{table, pending_resizes} = Table.pop_pending_resizes(table)
166+
acc = Enum.map(pending_resizes, &[<<0b001::3, Types.encode_integer(&1, 5)::bitstring>>])
167+
encode_headers(headers, table, acc)
153168
end
154169

155170
@doc """

lib/hpax/table.ex

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,25 @@ defmodule HPAX.Table do
33

44
@enforce_keys [:max_table_size, :huffman_encoding]
55
defstruct [
6+
:protocol_max_table_size,
67
:max_table_size,
78
:huffman_encoding,
89
entries: [],
910
size: 0,
10-
length: 0
11+
length: 0,
12+
pending_minimum_resize: nil
1113
]
1214

1315
@type huffman_encoding() :: :always | :never
1416

1517
@type t() :: %__MODULE__{
18+
protocol_max_table_size: non_neg_integer(),
1619
max_table_size: non_neg_integer(),
1720
huffman_encoding: huffman_encoding(),
1821
entries: [{binary(), binary()}],
1922
size: non_neg_integer(),
20-
length: non_neg_integer()
23+
length: non_neg_integer(),
24+
pending_minimum_resize: non_neg_integer() | nil
2125
}
2226

2327
@static_table [
@@ -94,10 +98,14 @@ defmodule HPAX.Table do
9498
http://httpwg.org/specs/rfc7541.html#maximum.table.size.
9599
"""
96100
@spec new(non_neg_integer(), huffman_encoding()) :: t()
97-
def new(max_table_size, huffman_encoding)
98-
when is_integer(max_table_size) and max_table_size >= 0 and
101+
def new(protocol_max_table_size, huffman_encoding)
102+
when is_integer(protocol_max_table_size) and protocol_max_table_size >= 0 and
99103
huffman_encoding in [:always, :never] do
100-
%__MODULE__{max_table_size: max_table_size, huffman_encoding: huffman_encoding}
104+
%__MODULE__{
105+
protocol_max_table_size: protocol_max_table_size,
106+
max_table_size: protocol_max_table_size,
107+
huffman_encoding: huffman_encoding
108+
}
101109
end
102110

103111
@doc """
@@ -124,7 +132,7 @@ defmodule HPAX.Table do
124132

125133
size + entry_size > max_table_size ->
126134
table
127-
|> resize(max_table_size - entry_size)
135+
|> evict_to_size(max_table_size - entry_size)
128136
|> add_header(name, value, entry_size)
129137

130138
true ->
@@ -242,15 +250,78 @@ defmodule HPAX.Table do
242250
end
243251

244252
@doc """
245-
Resizes the table.
253+
Changes the table's protocol negotiated maximum size, possibly evicting entries as needed to satisfy.
254+
255+
If the indicated size is less than the table's current max size, entries
256+
will be evicted as needed to fit within the specified size, and the table's
257+
maximum size will be decreased to the specified value. An will also be
258+
set which will enqueue a 'dynamic table size update' command to be prefixed
259+
to the next block encoded with this table, per RFC9113§4.3.1.
260+
261+
If the indicated size is greater than or equal to the table's current max size, no entries are evicted
262+
and the table's maximum size changes to the specified value.
246263
247-
If the existing entries do not fit in the new table size the oldest entries are evicted.
264+
In all cases, the table's `:protocol_max_table_size` is updated accordingly
248265
"""
249266
@spec resize(t(), non_neg_integer()) :: t()
250-
def resize(%__MODULE__{entries: entries, size: size} = table, new_size) do
251-
{new_entries_reversed, new_size} = evict_towards_size(Enum.reverse(entries), size, new_size)
267+
def resize(%__MODULE__{max_table_size: max_table_size} = table, new_protocol_max_table_size)
268+
when new_protocol_max_table_size >= max_table_size do
269+
%__MODULE__{
270+
table
271+
| protocol_max_table_size: new_protocol_max_table_size,
272+
max_table_size: new_protocol_max_table_size
273+
}
274+
end
275+
276+
def resize(%__MODULE__{} = table, new_protocol_max_table_size) do
277+
pending_minimum_resize =
278+
case table.pending_minimum_resize do
279+
nil -> new_protocol_max_table_size
280+
current -> min(current, new_protocol_max_table_size)
281+
end
282+
283+
%__MODULE__{
284+
evict_to_size(table, new_protocol_max_table_size)
285+
| protocol_max_table_size: new_protocol_max_table_size,
286+
max_table_size: new_protocol_max_table_size,
287+
pending_minimum_resize: pending_minimum_resize
288+
}
289+
end
290+
291+
def dynamic_resize(%__MODULE__{} = table, new_max_table_size) do
292+
%__MODULE__{
293+
evict_to_size(table, new_max_table_size)
294+
| max_table_size: new_max_table_size
295+
}
296+
end
297+
298+
@doc """
299+
Returns (and clears) any pending resize events on the table which will need to be signalled to
300+
the decoder via dynamic table size update messages. Intended to be called at the start of any
301+
block encode to prepend such dynamic table size update(s) as needed. The value of
302+
`pending_minimum_resize` indicates the smallest maximum size of this table which has not yet
303+
been signalled to the decoder, and is always included in the list returned if it is set.
304+
Additionally, if the current max table size is larger than this value, it is also included int
305+
the list, per https://www.rfc-editor.org/rfc/rfc7541#section-4.2
306+
"""
307+
def pop_pending_resizes(%{pending_minimum_resize: nil} = table), do: {table, []}
308+
309+
def pop_pending_resizes(table) do
310+
pending_resizes =
311+
if table.max_table_size > table.pending_minimum_resize,
312+
do: [table.pending_minimum_resize, table.max_table_size],
313+
else: [table.pending_minimum_resize]
314+
315+
{%__MODULE__{table | pending_minimum_resize: nil}, pending_resizes}
316+
end
317+
318+
# Removes records as necessary to have the total size of entries within the table be less than
319+
# or equal to the specified value. Does not change the table's max size.
320+
defp evict_to_size(%__MODULE__{entries: entries, size: size} = table, new_size) do
321+
{new_entries_reversed, new_size} =
322+
evict_towards_size(Enum.reverse(entries), size, new_size)
252323

253-
%{
324+
%__MODULE__{
254325
table
255326
| entries: Enum.reverse(new_entries_reversed),
256327
size: new_size,

test/hpax/table_test.exs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ defmodule HPAX.TableTest do
2626
assert {:name, _} = Table.lookup_by_header(table, ":my-header", nil)
2727
end
2828

29-
test "resizing" do
29+
test "LRU eviction" do
3030
dynamic_table_start = length(Table.__static_table__()) + 1
3131

3232
# This fits two headers that have name and value of 4 bytes (4 + 4 + 32, twice).
@@ -42,10 +42,6 @@ defmodule HPAX.TableTest do
4242
assert Table.lookup_by_index(table, dynamic_table_start) == {:ok, {"cccc", "CCCC"}}
4343
assert Table.lookup_by_index(table, dynamic_table_start + 1) == {:ok, {"bbbb", "BBBB"}}
4444
assert Table.lookup_by_index(table, dynamic_table_start + 2) == :error
45-
46-
# If we resize so that no headers fit, all headers are removed.
47-
table = Table.resize(table, 30)
48-
assert Table.lookup_by_index(table, dynamic_table_start) == :error
4945
end
5046

5147
describe "looking headers up by index" do
@@ -73,4 +69,36 @@ defmodule HPAX.TableTest do
7369
assert {:full, 62} = Table.lookup_by_header(table, name, value)
7470
end
7571
end
72+
73+
describe "resizing" do
74+
test "increasing the protocol max table size" do
75+
table = Table.new(4096, :never)
76+
table = Table.add(table, "aaaa", "AAAA")
77+
table = Table.resize(table, 8192)
78+
assert table.size == 40
79+
assert table.max_table_size == 8192
80+
assert table.protocol_max_table_size == 8192
81+
end
82+
83+
test "decreasing the protocol max table size not below the max table size" do
84+
table = Table.new(4096, :never)
85+
table = Table.add(table, "aaaa", "AAAA")
86+
table = Table.add(table, "bbbb", "BBBB")
87+
table = Table.dynamic_resize(table, 2048)
88+
table = Table.resize(table, 6000)
89+
assert table.size == 40
90+
assert table.max_table_size == 6000
91+
assert table.protocol_max_table_size == 6000
92+
end
93+
94+
test "decreasing the protocol max table size below the max table size" do
95+
table = Table.new(4096, :never)
96+
table = Table.add(table, "aaaa", "AAAA")
97+
table = Table.add(table, "bbbb", "BBBB")
98+
table = Table.resize(table, 40)
99+
assert table.size == 40
100+
assert table.max_table_size == 40
101+
assert table.protocol_max_table_size == 40
102+
end
103+
end
76104
end

test/hpax_test.exs

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,28 +107,62 @@ defmodule HPAXTest do
107107
end
108108
end
109109

110+
property "encode/3 prepends dynamic resizes at the start of a block" do
111+
enc_table = HPAX.new(20_000)
112+
# Start with a non-empty decode table
113+
dec_table = HPAX.new(20_000)
114+
115+
# Put a record in both to prime the pump. The table sizes should match
116+
{encoded, enc_table} = HPAX.encode([{:store, "bogus", "BOGUS"}], enc_table)
117+
encoded = IO.iodata_to_binary(encoded)
118+
assert {:ok, _decoded, dec_table} = HPAX.decode(encoded, dec_table)
119+
assert dec_table.size == enc_table.size
120+
assert enc_table.max_table_size == 20_000
121+
assert dec_table.max_table_size == 20_000
122+
123+
# Encode a record after resizing the table. We expect a dynamic resize to be
124+
# encoded and the for two table sizes to be identical after decoding
125+
enc_table = HPAX.resize(enc_table, 0)
126+
enc_table = HPAX.resize(enc_table, 1234)
127+
{encoded, enc_table} = HPAX.encode([{:store, "lame", "LAME"}], enc_table)
128+
encoded = IO.iodata_to_binary(encoded)
129+
130+
# Ensure that we see two resizes in order
131+
assert <<0b001::3, rest::bitstring>> = encoded
132+
assert {:ok, 0, rest} = HPAX.Types.decode_integer(rest, 5)
133+
assert <<0b001::3, rest::bitstring>> = rest
134+
assert {:ok, 1234, _rest} = HPAX.Types.decode_integer(rest, 5)
135+
136+
# Finally, ensure that the decoder makes proper sense of this encoding
137+
assert {:ok, _decoded, dec_table} = HPAX.decode(encoded, dec_table)
138+
assert dec_table.size == enc_table.size
139+
assert enc_table.max_table_size == 1234
140+
assert dec_table.max_table_size == 1234
141+
end
142+
110143
# https://datatracker.ietf.org/doc/html/rfc7541#section-4.2
111144
property "decode/2 accepts dynamic resizes at the start of a block" do
112145
enc_table = HPAX.new(20_000)
113146
# Start with a non-empty decode table
114147
dec_table = HPAX.new(20_000)
115-
{encoded, _enc_table} = HPAX.encode([{:store, "bogus", "BOGUS"}], dec_table)
148+
149+
# Put a record in both to prime the pump. The table sizes should match
150+
{encoded, enc_table} = HPAX.encode([{:store, "bogus", "BOGUS"}], enc_table)
116151
encoded = IO.iodata_to_binary(encoded)
117152
assert {:ok, _decoded, dec_table} = HPAX.decode(encoded, dec_table)
118-
assert dec_table.size > 0
119-
120-
check all headers_to_encode <- list_of(header_with_store(), min_length: 1) do
121-
assert {encoded, enc_table} = HPAX.encode(headers_to_encode, enc_table)
122-
encoded = IO.iodata_to_binary(encoded)
123-
assert {:ok, _decoded, new_dec_table} = HPAX.decode(encoded, dec_table)
124-
assert new_dec_table.size > enc_table.size
125-
126-
# Now prepend a table zeroing to the beginning and ensure that we are exactly
127-
# the same size as the encode table
128-
encoded = <<0b001::3, 0::5>> <> encoded
129-
assert {:ok, _decoded, new_dec_table} = HPAX.decode(encoded, dec_table)
130-
assert new_dec_table.size == enc_table.size
131-
end
153+
assert dec_table.size == enc_table.size
154+
assert enc_table.max_table_size == 20_000
155+
assert dec_table.max_table_size == 20_000
156+
157+
# Encode a record but prepend a resize to it. The decode side will now be
158+
# smaller since it only contains the newly added record
159+
old_enc_table_size = enc_table.size
160+
{encoded, _enc_table} = HPAX.encode([{:store, "lame", "LAME"}], dec_table)
161+
encoded = <<0b001::3, 0::5>> <> IO.iodata_to_binary(encoded)
162+
assert {:ok, _decoded, dec_table} = HPAX.decode(encoded, dec_table)
163+
assert dec_table.size == enc_table.size - old_enc_table_size
164+
assert enc_table.max_table_size == 20_000
165+
assert dec_table.max_table_size == 0
132166
end
133167

134168
# https://datatracker.ietf.org/doc/html/rfc7541#section-4.2

0 commit comments

Comments
 (0)