fast-protowire 0.1.0 → 0.3.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: a0c2962e443067325c5f4a089b72e499b9e273f8f563464e9830ed9cdf6eccfa
4
- data.tar.gz: cdc454d06627bf2d1f28ddc35127024e119e06e748120cfceac91fa7e4e40860
3
+ metadata.gz: 9d6f10862b8163b7b4d5210479a6b27b62298e0951b24425db97cdec94356a10
4
+ data.tar.gz: 5a882709766e557ae6a65b3107ec3cc8de6fba07f90de3293607518cf880b4f8
5
5
  SHA512:
6
- metadata.gz: 5b6b5ea5c39ebc3fb9b25490fbea312dccdaaf450ab08b7a9be794056dbc3565e814f98890629664e405333e340e61d493760a57adc99b072b2cf59ff8399bc6
7
- data.tar.gz: b788d96fe73458a33391b5339d35f7be57a37ec3bb203a2d645c590a5cb98d6ef4f9a5641788d8a12f20c7a51911c27287cba01cbac49e0d4b3c9e22f944be1d
6
+ metadata.gz: 8a54a74df181260136e00d9227f050c1ef785096515b4a91197f939af58df1d865bea3eb5e660db97335111a1728407edccfce11afd8b60a4335076e95b1a258
7
+ data.tar.gz: 5048f2b557f2e3c4f44b4a2cb95110b11142c6b7c94975dbca68f7a3fc1550ea2e9b120460b4e36bb2ed7f316bdb476b60b2b334f449728c2a8ba869242a8036
data/AGENTS.md CHANGED
@@ -8,7 +8,9 @@ Standing context for agents working in this repository.
8
8
  bundle install
9
9
  bundle exec sus
10
10
  bundle exec rubocop
11
- protoc --proto_path=fixtures/proto --ruby_out=fixtures/pb fixtures/proto/*.proto # regenerate the reference schemas
11
+ bundle exec ruby benchmark/messages.rb # encode, build and decode against google-protobuf; BENCH_QUICK=1 for a short run
12
+ protoc --proto_path=fixtures/proto --proto_path=/opt/homebrew/include --ruby_out=fixtures/pb fixtures/proto/*.proto
13
+ # regenerates the reference schemas; the second path is where protoc keeps google/protobuf/timestamp.proto
12
14
  ```
13
15
 
14
16
  ## What this is
data/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - A declared field arriving with a wire type it does not accept is kept as
6
+ an unknown field and written back verbatim, where it used to raise
7
+ `DecodeError`: schema drift is not corruption, and the reference keeps it.
8
+ So is a map entry carrying more than a key and a value — an undeclared
9
+ subfield, or a key or value with a wire type the entry does not accept —
10
+ kept whole among the parent message's unknown fields and left out of the
11
+ map. An entry that omits its key or its value decodes to that type's
12
+ default, an empty message for a message-typed value.
13
+ - Hostile input is bounded rather than fatal. Nesting deeper than
14
+ `Reader::MAX_DEPTH` (100, the reference's limit; nested messages, map
15
+ entries and groups count, a packed field does not) raises `DecodeError`
16
+ instead of overflowing the VM stack with `SystemStackError`. Field number
17
+ 0 anywhere in the input, a group closed by an `END_GROUP` carrying another
18
+ number, and a proto3 `string` whose bytes are not valid UTF-8 raise
19
+ `DecodeError` too. A varint carrying bits above 64 is truncated rather
20
+ than read wide, as every implementation does, and a `sint32` is truncated
21
+ to 32 bits before its zigzag is undone.
22
+ - Decoding is about bytes, so a `Reader` reads its input whatever the String
23
+ is tagged: a non-binary one is read through a binary view made once on
24
+ construction, and the input is never modified. `encode` holds its buffer
25
+ to the same rule: an empty String of any encoding is retagged binary, and
26
+ one already holding text raises `ArgumentError` rather than widening the
27
+ bytes it appends into that encoding's characters.
28
+ - `Message#==`, `eql?` and `hash` compare the declared fields only. Unknown
29
+ fields are no longer part of the comparison, as the reference does not
30
+ compare them; they are still kept, still readable through
31
+ `unknown_fields`, and still written back by `encode`. Byte identity is
32
+ `a.encode == b.encode`.
33
+ - A `float` field narrows to single precision on assignment rather than on
34
+ encode, so what it reads back is what the wire carries and
35
+ `decode(encode(m)) == m` holds: `0.1` reads back `0.10000000149011612`,
36
+ `3.5e38` reads `Infinity`, `1e-50` reads `0.0`. A 32-bit NaN is still
37
+ written canonical (`7fc00000`) because Ruby's `pack("e")` drops the sign
38
+ and payload bits the reference preserves; that one is documented, not
39
+ fixed. `double` NaNs round-trip whole.
40
+ - A declaration the wire format has no form for raises `ArgumentError` where
41
+ it is written rather than at the first encode: a field number outside
42
+ 1 ... 2²⁹−1 or inside 19000 ... 19999, which the specification reserves for
43
+ the implementation, and `packed: true` on a `string`, `bytes` or message
44
+ field.
45
+ - `Reader#read_packed` is back for packed fields, which are read in place
46
+ and cost no depth level (0.2.0's entry recorded it removed).
47
+ `Reader#read_key` returns a tag as the raw key holding number and wire
48
+ type together; `Reader#skip` takes the field number the value arrived
49
+ under, `skip(wire_type, number = nil)`, so a group is closed only by its
50
+ own number; `Field#accepts?(wire_type)`, `Wire.binary_buffer` and
51
+ `Wire::UINT64_MASK` are public.
52
+ - Decoding costs what it did on 0.2.0, guards included: the 36,000-metric
53
+ family decodes in 0.801 s where 0.2.0 takes 0.784 s on the same machine
54
+ and Ruby (4.0.7, five timed calls, same session). A field's wire type is
55
+ computed once, when the field is declared, rather than on every read,
56
+ where the per-field check had been paying for it twice. What it allocates
57
+ is unchanged, 1,404,005 objects for that family and one object to encode
58
+ it, and so are the budgets in `test/fast/protowire/allocations.rb`; the
59
+ benchmarks page carries the re-measured tables.
60
+
61
+ ## 0.2.0
62
+
63
+ - Encoding allocates nothing per nested message, packed field or map
64
+ entry: each is written into the parent buffer behind a length prefix
65
+ filled in afterwards. A family of 36,000 metrics x 12 labels encodes in
66
+ one object, its output, where it took 647,000, and 22% faster.
67
+ `Wire.append_length_delimited_from` now appends in place and returns the
68
+ width of the prefix it wrote, a hint to pass back for the next value;
69
+ `Wire.reserve_length` and `Wire.close_length` are its pieces, and
70
+ `Wire.varint_size` and `Wire.append_bytes` are public.
71
+ - Fixed-width values pack with literal formats, so a double costs no Array
72
+ on Ruby 3.4+; text is appended with `String#append_as_bytes` there, tag
73
+ and one-byte size in the same call and no binary copy for UTF-8.
74
+ Zigzag no longer allocates for negative values.
75
+ - Decoding reads nested messages, packed fields and map entries in place
76
+ (`Reader#read_nested`), unpacks fixed-width values at an offset
77
+ (`Reader#read_fixed`) and splits tags without an Array, so it allocates
78
+ only the messages, containers and Strings it returns: the same family
79
+ decodes in 1.4M objects where it took 7.1M, 1.7x faster.
80
+ - `Message.new` no longer allocates an empty keyword Hash per message.
81
+ - Removed: `Reader#read_packed` (use `read_nested`) and the interpretive
82
+ `Field#encode`, which the compiled encoder had superseded.
83
+ - `test/fast/protowire/allocations.rb` holds the budgets; the Prometheus
84
+ client model (`fixtures/proto/metrics.proto`, upstream `client_model`)
85
+ joins the parity suite; `benchmark/messages.rb` measures encode, build
86
+ and decode against google-protobuf, published on the benchmarks page.
87
+
3
88
  ## 0.1.0
4
89
 
5
90
  - Initial release: `Wire`, `Reader`, `Enum` and the `Message` DSL covering
data/README.md CHANGED
@@ -50,6 +50,23 @@ and the output is byte-identical to what `protoc`-generated code and `google-pro
50
50
  produce for the same values. The [tutorial](docs/tutorials/encode-a-message.md) walks
51
51
  through a full schema and proves that.
52
52
 
53
+ ## Performance
54
+
55
+ Measured against `google-protobuf` on the Prometheus client model, a family of 36,000
56
+ metrics with twelve labels each (Ruby 4.0.7; conditions and every table on the
57
+ [benchmarks page](docs/explanation/benchmarks.md)):
58
+
59
+ - Encoding allocates **one object, the output**, whatever the message's size or depth;
60
+ 0.1.0 allocated 647,000 for this family. Decoding allocates only the messages,
61
+ containers and Strings it returns, 5x fewer than 0.1.0 and 1.3x faster.
62
+ - Built a message at a time, the way an exposition builds series, `google-protobuf`
63
+ leaves **504,001 native arenas and 225 MiB** behind for an 11.76 MB body and spends
64
+ 1.77 s of every ten builds in GC; fast-protowire leaves 16.5 MiB, no arenas, and 0.27 s.
65
+ - `google-protobuf` is native, and 9 to 25x faster per operation on an existing tree
66
+ or one nested Hash. This gem trades that speed for memory that is roughly the size of
67
+ the output; fast-prometheus's scrape path goes further and writes series with `Wire`
68
+ directly, with no message per series at all.
69
+
53
70
  ## Documentation
54
71
 
55
72
  ### Tutorials
@@ -73,10 +90,12 @@ through a full schema and proves that.
73
90
 
74
91
  - [Design: the wire format and nothing else](docs/explanation/design.md) — what google-protobuf costs per message, what this gem does instead, and what it leaves out.
75
92
  - [How encoding works](docs/explanation/encoding.md) — compiled encoders, buffers, field order, presence, and decoding.
93
+ - [Benchmarks](docs/explanation/benchmarks.md) — encode, build and decode against google-protobuf, what changed since 0.1.0, and how to reproduce them.
76
94
 
77
95
  ## Development
78
96
 
79
97
  ```bash
80
98
  bundle exec sus
81
99
  bundle exec rubocop
100
+ BENCH_QUICK=1 bundle exec ruby benchmark/messages.rb # encode, build and decode against google-protobuf
82
101
  ```
@@ -30,9 +30,15 @@ module Fast
30
30
 
31
31
  MAP_KEY_TYPES = %i[int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string].freeze
32
32
 
33
- private_constant :SCALAR_WIRE_TYPES, :INTEGER_RANGES, :FIXED_FORMATS, :PACKABLE, :MAP_KEY_TYPES
33
+ # The field numbers a tag can carry, less the range the specification
34
+ # reserves for the implementation.
35
+ NUMBERS = (1..(1 << 29) - 1)
36
+ RESERVED_NUMBERS = (19_000..19_999)
34
37
 
35
- attr_reader :name, :number, :type, :rule, :oneof, :ivar, :enum
38
+ private_constant :SCALAR_WIRE_TYPES, :INTEGER_RANGES, :FIXED_FORMATS, :PACKABLE, :MAP_KEY_TYPES,
39
+ :NUMBERS, :RESERVED_NUMBERS
40
+
41
+ attr_reader :name, :number, :type, :rule, :oneof, :ivar, :enum, :wire_type
36
42
 
37
43
  # +type+ is a scalar Symbol, an Enum module, a Message class, or a
38
44
  # String / Proc naming a Message class resolved on first use (for
@@ -40,13 +46,17 @@ module Fast
40
46
  # :repeated or :map.
41
47
  def initialize(name, type, number, rule:, owner:, packed: nil, default: nil, oneof: nil, key_type: nil)
42
48
  @name = name
43
- @number = number
49
+ @number = validate_number(number)
44
50
  @rule = rule
45
51
  @owner = owner
46
52
  @oneof = oneof
47
53
  @ivar = :"@#{name}"
48
54
  resolve_type(type)
55
+ @wire_type = rule == :map ? Wire::LENGTH_DELIMITED : SCALAR_WIRE_TYPES.fetch(@type)
56
+ raise ArgumentError, "#{name}: a #{@type} field cannot be packed" if packed && !packable?
57
+
49
58
  @packed = rule == :repeated && (packed.nil? ? owner.syntax == :proto3 && packable? : packed)
59
+ @strict_utf8 = @type == :string && owner.syntax == :proto3
50
60
  @default = default
51
61
  @tag = Wire.tag(number, packed? ? Wire::LENGTH_DELIMITED : wire_type)
52
62
  return unless map?
@@ -55,6 +65,9 @@ module Fast
55
65
 
56
66
  @key_field = Field.new(:key, key_type, 1, rule: :optional, owner: owner)
57
67
  @value_field = Field.new(:value, type, 2, rule: :optional, owner: owner)
68
+ # The only two tags an entry accepts: number and wire type together.
69
+ @key_tag = (1 << 3) | @key_field.wire_type
70
+ @value_tag = (2 << 3) | @value_field.wire_type
58
71
  end
59
72
 
60
73
  def message_class
@@ -65,10 +78,6 @@ module Fast
65
78
  end
66
79
  end
67
80
 
68
- def wire_type
69
- map? ? Wire::LENGTH_DELIMITED : SCALAR_WIRE_TYPES.fetch(type)
70
- end
71
-
72
81
  def repeated?
73
82
  rule == :repeated
74
83
  end
@@ -85,6 +94,13 @@ module Fast
85
94
  PACKABLE.include?(type)
86
95
  end
87
96
 
97
+ # Whether a value of +wire_type+ can be read into this field: its own,
98
+ # plus the packed form of a packable repeated field however it was
99
+ # declared. Anything else is schema drift, kept as an unknown field.
100
+ def accepts?(wire_type)
101
+ wire_type == self.wire_type || (repeated? && packable? && wire_type == Wire::LENGTH_DELIMITED)
102
+ end
103
+
88
104
  # Whether an unset field is distinguishable from one set to its default.
89
105
  def explicit_presence?
90
106
  rule != :implicit || !oneof.nil? || type == :message
@@ -141,23 +157,17 @@ module Fast
141
157
  end
142
158
  end
143
159
 
144
- def encode(buffer, value)
145
- case rule
146
- when :repeated then encode_repeated(buffer, value)
147
- when :map then encode_map(buffer, value)
148
- else encode_one(buffer, value)
149
- end
150
- end
151
-
152
160
  # Reads one occurrence of this field into +current+ (the value already
153
- # held) and returns the value to store.
154
- def decode(reader, wire_type, current)
161
+ # held) and returns the value to store. +message+ is the message being
162
+ # read into, for a map entry it cannot accept: that belongs among the
163
+ # message's unknown fields rather than in the map.
164
+ def decode(reader, wire_type, current, message)
155
165
  case rule
156
166
  when :repeated then decode_repeated(reader, wire_type, current)
157
- when :map then decode_map_entry(reader, wire_type, current)
167
+ when :map then decode_map_entry(reader, wire_type, current, message)
158
168
  else
159
169
  if type == :message && current
160
- current.merge_from(Reader.new(expect(reader, wire_type).read_length_delimited))
170
+ expect(reader, wire_type).read_nested { |nested| current.merge_from(nested) }
161
171
  else
162
172
  read_one(reader, wire_type)
163
173
  end
@@ -166,6 +176,15 @@ module Fast
166
176
 
167
177
  protected
168
178
 
179
+ # A second occurrence of a message field merges into the first; of
180
+ # anything else, replaces it. (A map's value field is read this way by
181
+ # the map field, which is why this is protected rather than private.)
182
+ def decode_singular(reader, wire_type, current)
183
+ return read_one(reader, wire_type) unless type == :message && current
184
+
185
+ expect(reader, wire_type).read_nested { |nested| current.merge_from(nested) }
186
+ end
187
+
169
188
  def coerce_one(value)
170
189
  case type
171
190
  when :string then coerce_string(value)
@@ -178,28 +197,55 @@ module Fast
178
197
  end
179
198
  end
180
199
 
181
- def encode_one(buffer, value)
182
- case type
183
- when :message then Wire.append_length_delimited(buffer, @tag, value.encode)
184
- when :string, :bytes then Wire.append_length_delimited(buffer, @tag, value)
185
- else
186
- buffer << @tag
187
- append_scalar(buffer, value)
188
- end
189
- end
190
-
191
200
  def read_one(reader, wire_type)
192
201
  expect(reader, wire_type)
193
202
  case type
194
- when :message then message_class.decode(reader.read_length_delimited)
195
- when :string then reader.read_length_delimited.force_encoding(Encoding::UTF_8)
203
+ when :message then reader.read_nested { |nested| message_class.new.merge_from(nested) }
204
+ when :string
205
+ @strict_utf8 ? read_string(reader) : reader.read_length_delimited.force_encoding(Encoding::UTF_8)
196
206
  when :bytes then reader.read_length_delimited
197
207
  else read_scalar(reader)
198
208
  end
199
209
  end
200
210
 
211
+ # What a map entry holds for this field when the entry omits it: the
212
+ # type's default, except that a message value is an empty instance, as
213
+ # the reference materialises one (and nil would not encode).
214
+ def entry_default
215
+ type == :message ? message_class.new : default_value
216
+ end
217
+
218
+ # Appends the tag and one value. A nested message is encoded straight
219
+ # into the buffer behind a length prefix filled in after it, with no
220
+ # buffer of its own; +width+ carries the prefix width from one value
221
+ # to the next, a hint shared by every encode of the field. (A map's
222
+ # key and value fields build the entry writer together, which is why
223
+ # this is protected rather than private.)
224
+ def writer
225
+ tag = @tag
226
+ case type
227
+ when :message
228
+ width = 1
229
+ ->(buffer, value) { width = Wire.append_length_delimited_from(buffer, tag, width) { |b| value.encode(b) } }
230
+ when :string, :bytes then ->(buffer, value) { Wire.append_length_delimited(buffer, tag, value) }
231
+ else
232
+ scalar = scalar_writer
233
+ lambda do |buffer, value|
234
+ buffer << tag
235
+ scalar.call(buffer, value)
236
+ end
237
+ end
238
+ end
239
+
201
240
  private
202
241
 
242
+ def validate_number(number)
243
+ raise ArgumentError, "field number #{number} is reserved" if RESERVED_NUMBERS.cover?(number)
244
+ raise ArgumentError, "field number #{number} is outside #{NUMBERS}" unless NUMBERS.cover?(number)
245
+
246
+ number
247
+ end
248
+
203
249
  def resolve_type(type)
204
250
  case type
205
251
  when Symbol
@@ -265,17 +311,19 @@ module Fast
265
311
  end
266
312
  end
267
313
 
314
+ # Packed: every value bare, behind one tag and length.
268
315
  def repeated_step(ivar)
269
316
  if packed?
270
317
  tag = @tag
271
318
  scalar = scalar_writer
319
+ width = 1
272
320
  lambda do |message, buffer|
273
321
  values = message.instance_variable_get(ivar)
274
322
  next if values.empty?
275
323
 
276
- payload = String.new
277
- values.each { |value| scalar.call(payload, value) }
278
- Wire.append_length_delimited(buffer, tag, payload)
324
+ width = Wire.append_length_delimited_from(buffer, tag, width) do |b|
325
+ values.each { |value| scalar.call(b, value) }
326
+ end
279
327
  end
280
328
  else
281
329
  write = writer
@@ -283,36 +331,26 @@ module Fast
283
331
  end
284
332
  end
285
333
 
334
+ # Each entry is a message { key = 1; value = 2 }.
286
335
  def map_step(ivar)
287
336
  tag = @tag
288
- key = @key_field.writer
289
- value = @value_field.writer
337
+ key, value = entry_writers
338
+ width = 1
290
339
  lambda do |message, buffer|
291
340
  message.instance_variable_get(ivar).each do |k, v|
292
- entry = String.new
293
- key.call(entry, k)
294
- value.call(entry, v)
295
- Wire.append_length_delimited(buffer, tag, entry)
341
+ width = Wire.append_length_delimited_from(buffer, tag, width) do |b|
342
+ key.call(b, k)
343
+ value.call(b, v)
344
+ end
296
345
  end
297
346
  end
298
347
  end
299
348
 
300
- # Appends the tag and one value.
301
- def writer
302
- tag = @tag
303
- case type
304
- when :message then ->(buffer, value) { Wire.append_length_delimited(buffer, tag, value.encode) }
305
- when :string, :bytes then ->(buffer, value) { Wire.append_length_delimited(buffer, tag, value) }
306
- else
307
- scalar = scalar_writer
308
- lambda do |buffer, value|
309
- buffer << tag
310
- scalar.call(buffer, value)
311
- end
312
- end
313
- end
349
+ # -- writers -----------------------------------------------------------
314
350
 
315
351
  # Appends one scalar value without its tag, as packed fields need.
352
+ # Fixed-width types get a lambda each so the pack format is a literal:
353
+ # Ruby elides the Array in [value].pack(literal, buffer:), and only then.
316
354
  def scalar_writer
317
355
  case type
318
356
  when :int32, :int64, :uint32, :uint64 then ->(buffer, value) { Wire.append_varint(buffer, value) }
@@ -322,9 +360,12 @@ module Fast
322
360
  when :enum
323
361
  enum = @enum
324
362
  ->(buffer, value) { Wire.append_varint(buffer, value.is_a?(Symbol) ? enum.resolve(value) : value) }
325
- else
326
- format = FIXED_FORMATS.fetch(type)
327
- ->(buffer, value) { [value].pack(format, buffer: buffer) }
363
+ when :double then ->(buffer, value) { [value].pack("E", buffer: buffer) }
364
+ when :float then ->(buffer, value) { [value].pack("e", buffer: buffer) }
365
+ when :fixed32 then ->(buffer, value) { [value].pack("L<", buffer: buffer) }
366
+ when :sfixed32 then ->(buffer, value) { [value].pack("l<", buffer: buffer) }
367
+ when :fixed64 then ->(buffer, value) { [value].pack("Q<", buffer: buffer) }
368
+ when :sfixed64 then ->(buffer, value) { [value].pack("q<", buffer: buffer) }
328
369
  end
329
370
  end
330
371
 
@@ -365,10 +406,13 @@ module Fast
365
406
  value.encoding == Encoding::BINARY ? value : value.b
366
407
  end
367
408
 
409
+ # A +float+ holds what the wire holds: the value narrowed to single
410
+ # precision on assignment, as the reference narrows it, so what is
411
+ # read back is what a decode of the encoding reads.
368
412
  def coerce_float(value)
369
413
  raise ::TypeError, "#{name} expects a number, got #{value.class}" unless value.is_a?(Numeric)
370
414
 
371
- value.to_f
415
+ type == :float ? [value.to_f].pack("e").unpack1("e") : value.to_f
372
416
  end
373
417
 
374
418
  def coerce_bool(value)
@@ -409,66 +453,68 @@ module Fast
409
453
  integer
410
454
  end
411
455
 
412
- # -- encoding ----------------------------------------------------------
413
-
414
- def encode_repeated(buffer, values)
415
- return if values.empty?
416
-
417
- if packed?
418
- Wire.append_length_delimited_from(buffer, @tag) { |b| values.each { |v| append_scalar(b, v) } }
419
- else
420
- values.each { |v| encode_one(buffer, v) }
421
- end
422
- end
423
-
424
- def encode_map(buffer, hash)
425
- hash.each do |key, value|
426
- Wire.append_length_delimited_from(buffer, @tag) do |entry|
427
- @key_field.encode_one(entry, key)
428
- @value_field.encode_one(entry, value)
429
- end
430
- end
431
- end
432
-
433
- def append_scalar(buffer, value)
434
- case type
435
- when :int32, :int64, :uint32, :uint64 then Wire.append_varint(buffer, value)
436
- when :sint32 then Wire.append_varint(buffer, Wire.zigzag32(value))
437
- when :sint64 then Wire.append_varint(buffer, Wire.zigzag64(value))
438
- when :bool then buffer << (value ? 1 : 0)
439
- when :enum then Wire.append_varint(buffer, enum_number(value))
440
- else buffer << [value].pack(FIXED_FORMATS.fetch(type))
441
- end
442
- end
443
-
444
456
  # -- decoding ----------------------------------------------------------
445
457
 
446
458
  def decode_repeated(reader, wire_type, values)
447
459
  if packable? && wire_type == Wire::LENGTH_DELIMITED
448
- packed = reader.read_packed
449
- values << read_scalar(packed) until packed.eof?
460
+ reader.read_packed { |packed| values << read_scalar(packed) until packed.eof? }
450
461
  else
451
462
  values << read_one(reader, wire_type)
452
463
  end
453
464
  values
454
465
  end
455
466
 
456
- def decode_map_entry(reader, wire_type, hash)
457
- entry = Reader.new(expect(reader, wire_type).read_length_delimited)
458
- key = @key_field.default_value
459
- value = @value_field.default_value
460
- until entry.eof?
461
- number, entry_wire_type = entry.read_tag
462
- case number
463
- when 1 then key = @key_field.read_one(entry, entry_wire_type)
464
- when 2 then value = @value_field.decode(entry, entry_wire_type, value)
465
- else entry.skip(entry_wire_type)
467
+ # An entry the map accepts holds its key, its value, or both; one that
468
+ # carries anything else — an undeclared subfield, or the key or value
469
+ # with a wire type the entry does not accept — is not a map entry at
470
+ # all. The reference keeps such an entry among the parent message's
471
+ # unknown fields and leaves the map alone, so this does too.
472
+ def decode_map_entry(reader, wire_type, hash, message)
473
+ expect(reader, wire_type).read_nested do |entry|
474
+ key = nil
475
+ value = nil
476
+ unknown = nil
477
+ until entry.eof?
478
+ tag = entry.read_key
479
+ case tag
480
+ when @key_tag then key = @key_field.read_one(entry, tag & 0x7)
481
+ when @value_tag then value = @value_field.decode_singular(entry, tag & 0x7, value)
482
+ else
483
+ Wire.append_varint(unknown ||= String.new, tag)
484
+ unknown << entry.skip(tag & 0x7, tag >> 3)
485
+ end
486
+ end
487
+ if unknown
488
+ keep_entry(message, key, value, unknown)
489
+ else
490
+ # An absent key or value reads as its type's default, an empty
491
+ # message for a message-typed value, as the reference
492
+ # materialises one. (Only nil is absent: false is a bool key.)
493
+ hash[key.nil? ? @key_field.default_value : key] = value.nil? ? @value_field.entry_default : value
466
494
  end
467
495
  end
468
- hash[key] = value
469
496
  hash
470
497
  end
471
498
 
499
+ # A rejected entry, into the message's unknown fields (its own buffer,
500
+ # made here when the message has none yet) and written the way the
501
+ # reference writes one: the subfields it did carry, in number order and
502
+ # omitted when they are at their default — a message value is written
503
+ # whenever it was there — then the bytes it carried besides.
504
+ def keep_entry(message, key, value, unknown)
505
+ buffer = message.unknown_fields || message.instance_variable_set(:@unknown_fields, String.new)
506
+ write_key, write_value = entry_writers
507
+ Wire.append_length_delimited_from(buffer, @tag) do |b|
508
+ write_key.call(b, key) unless key.nil? || @key_field.omit?(key)
509
+ write_value.call(b, value) unless value.nil? || @value_field.omit?(value)
510
+ b << unknown
511
+ end
512
+ end
513
+
514
+ def entry_writers
515
+ @entry_writers ||= [@key_field.writer, @value_field.writer]
516
+ end
517
+
472
518
  def expect(reader, wire_type)
473
519
  return reader if wire_type == self.wire_type
474
520
 
@@ -482,20 +528,27 @@ module Fast
482
528
  when :int64 then signed(reader.read_varint, 64)
483
529
  when :uint32 then reader.read_varint & 0xFFFF_FFFF
484
530
  when :uint64 then reader.read_varint
485
- when :sint32, :sint64 then Wire.unzigzag(reader.read_varint)
531
+ when :sint32 then Wire.unzigzag(reader.read_varint & 0xFFFF_FFFF)
532
+ when :sint64 then Wire.unzigzag(reader.read_varint)
486
533
  when :bool then reader.read_varint != 0
487
- when :double, :fixed64, :sfixed64 then reader.read_bytes(8).unpack1(FIXED_FORMATS.fetch(type))
488
- else reader.read_bytes(4).unpack1(FIXED_FORMATS.fetch(type))
534
+ when :double, :fixed64, :sfixed64 then reader.read_fixed(FIXED_FORMATS.fetch(type), 8)
535
+ else reader.read_fixed(FIXED_FORMATS.fetch(type), 4)
489
536
  end
490
537
  end
491
538
 
539
+ # proto3 requires a parser to reject a string field that is not valid
540
+ # UTF-8; proto2 does not, and the reference keeps it.
541
+ def read_string(reader)
542
+ string = reader.read_length_delimited.force_encoding(Encoding::UTF_8)
543
+ raise DecodeError, "#{name}: string is not valid UTF-8" if @strict_utf8 && !string.valid_encoding?
544
+
545
+ string
546
+ end
547
+
492
548
  def signed(value, bits)
493
549
  value &= (1 << bits) - 1
494
550
  value >= (1 << (bits - 1)) ? value - (1 << bits) : value
495
551
  end
496
-
497
- # A map's key and value fields build the entry writer together.
498
- protected :writer
499
552
  end
500
553
  end
501
554
  end
@@ -97,6 +97,7 @@ module Fast
97
97
  def compile_encoder
98
98
  steps = sorted_fields.map(&:encoder_step)
99
99
  define_method(:encode) do |buffer = String.new|
100
+ Wire.binary_buffer(buffer)
100
101
  steps.each { |step| step.call(self, buffer) }
101
102
  buffer << @unknown_fields if @unknown_fields
102
103
  buffer
@@ -143,11 +144,12 @@ module Fast
143
144
 
144
145
  # Scalar and message ivars stay unset until written (an unset ivar reads
145
146
  # as nil); repeated and map fields get their container up front so it
146
- # can be mutated in place.
147
- def initialize(attributes = nil, **keywords)
147
+ # can be mutated in place. +attributes+ is a Hash, or the keywords a
148
+ # caller writes instead of one.
149
+ def initialize(attributes = nil)
148
150
  self.class.container_fields.each { |field| instance_variable_set(field.ivar, field.default_value) }
149
151
  @unknown_fields = nil
150
- (attributes || keywords).each do |name, value|
152
+ attributes&.each do |name, value|
151
153
  field = self.class.fields[name.to_sym]
152
154
  raise ArgumentError, "unknown field #{name.inspect} for #{self.class}" unless field
153
155
 
@@ -171,15 +173,23 @@ module Fast
171
173
 
172
174
  # Reads fields from +reader+ into this message (protobuf merge
173
175
  # semantics: later scalars win, repeated fields append, nested messages
174
- # merge) and returns self.
176
+ # merge) and returns self. A declared field arriving with a wire type
177
+ # it does not accept is schema drift rather than corruption, so it is
178
+ # kept as an unknown field, as the reference does; so is a map entry
179
+ # carrying a subfield the entry does not accept, kept whole.
175
180
  def merge_from(reader)
176
181
  until reader.eof?
177
- number, wire_type = reader.read_tag
182
+ key = reader.read_varint
183
+ wire_type = key & 0x7
184
+ number = key >> 3
185
+ raise DecodeError, "field number 0" if number.zero? # as Reader#read_key, inlined for the loop
186
+
178
187
  field = self.class.fields_by_number[number]
179
- if field
180
- write_field(field, field.decode(reader, wire_type, instance_variable_get(field.ivar)))
188
+ if field&.accepts?(wire_type)
189
+ write_field(field, field.decode(reader, wire_type, instance_variable_get(field.ivar), self))
181
190
  else
182
- (@unknown_fields ||= String.new) << Wire.varint((number << 3) | wire_type) << reader.skip(wire_type)
191
+ Wire.append_varint(@unknown_fields ||= String.new, key)
192
+ @unknown_fields << reader.skip(wire_type, number)
183
193
  end
184
194
  end
185
195
  self
@@ -194,8 +204,12 @@ module Fast
194
204
  end
195
205
  end
196
206
 
197
- # Two messages are equal when every field reads the same; an implicit
198
- # field set to its default is the same as one never set.
207
+ # Two messages are equal when every declared field reads the same; an
208
+ # implicit field set to its default is the same as one never set.
209
+ # Unknown fields are not compared, as the reference does not compare
210
+ # them: two messages that differ only in what they carry for fields
211
+ # this class never declared are the same message. Byte identity is
212
+ # +a.encode == b.encode+.
199
213
  def ==(other)
200
214
  other.class == self.class && comparable_values == other.comparable_values
201
215
  end
@@ -225,11 +239,10 @@ module Fast
225
239
  protected
226
240
 
227
241
  def comparable_values
228
- values = self.class.fields.each_value.map do |field|
242
+ self.class.fields.each_value.map do |field|
229
243
  value = instance_variable_get(field.ivar)
230
244
  value.nil? && !field.explicit_presence? ? field.default_value : value
231
245
  end
232
- values << @unknown_fields
233
246
  end
234
247
 
235
248
  private
@@ -8,10 +8,18 @@ module Fast
8
8
  # A cursor over an encoded message. Reads the primitive wire values and
9
9
  # skips what it is not asked to interpret.
10
10
  class Reader
11
+ # How deep nested messages and groups may go before the input is taken
12
+ # to be hostile rather than deep; the reference's limit.
13
+ MAX_DEPTH = 100
14
+
15
+ # Decoding is about bytes, so the input is read as bytes: a String
16
+ # tagged anything else is copied into a binary view once, here, rather
17
+ # than leaking its encoding into every slice handed out below.
11
18
  def initialize(buffer, position = 0, limit = buffer.bytesize)
12
- @buffer = buffer
19
+ @buffer = buffer.encoding == Encoding::BINARY ? buffer : buffer.b
13
20
  @position = position
14
21
  @limit = limit
22
+ @depth = 0
15
23
  end
16
24
 
17
25
  attr_reader :position
@@ -22,32 +30,56 @@ module Fast
22
30
 
23
31
  # Returns [field number, wire type].
24
32
  def read_tag
25
- key = read_varint
33
+ key = read_key
26
34
  [key >> 3, key & 0x7]
27
35
  end
28
36
 
37
+ # The next field's key, the varint holding its number and wire type
38
+ # together. Field number 0 exists on no wire: the reference rejects it
39
+ # wherever it appears, and so does this.
40
+ def read_key
41
+ key = read_varint
42
+ raise DecodeError, "field number 0" if key < 8
43
+
44
+ key
45
+ end
46
+
47
+ # Most varints (tags, small lengths) are one byte; the loop is only
48
+ # entered past it, and is a bare while because Kernel#loop costs an
49
+ # object per call.
29
50
  def read_varint
30
- result = 0
31
- shift = 0
32
- loop do
33
- raise DecodeError, "truncated varint" if @position >= @limit
51
+ byte = read_byte
52
+ return byte if byte < 0x80
34
53
 
35
- byte = @buffer.getbyte(@position)
36
- @position += 1
37
- result |= (byte & 0x7f) << shift
38
- return result if byte < 0x80
54
+ result = byte & 0x7f
55
+ shift = 7
56
+ while byte >= 0x80
57
+ raise DecodeError, "varint too long" if shift > 63
39
58
 
59
+ byte = read_byte
60
+ result |= (byte & 0x7f) << shift
40
61
  shift += 7
41
- raise DecodeError, "varint too long" if shift > 63
42
62
  end
63
+ # Only a tenth byte can carry bits above 64; every implementation
64
+ # truncates them rather than reading a wider value.
65
+ shift > 63 ? result & Wire::UINT64_MASK : result
43
66
  end
44
67
 
45
68
  def read_fixed32
46
- read_bytes(4).unpack1("L<")
69
+ read_fixed("L<", 4)
47
70
  end
48
71
 
49
72
  def read_fixed64
50
- read_bytes(8).unpack1("Q<")
73
+ read_fixed("Q<", 8)
74
+ end
75
+
76
+ # Reads +width+ bytes as one value of the pack +format+, in place.
77
+ def read_fixed(format, width)
78
+ raise DecodeError, "truncated field" if @position + width > @limit
79
+
80
+ value = @buffer.unpack1(format, offset: @position)
81
+ @position += width
82
+ value
51
83
  end
52
84
 
53
85
  def read_bytes(length)
@@ -62,25 +94,53 @@ module Fast
62
94
  read_bytes(read_varint)
63
95
  end
64
96
 
65
- # A reader bounded to the next length-delimited value, for packed fields.
97
+ # Bounds the reader to the next length-delimited value for the block
98
+ # and returns the block's result, so a packed repeated field is read
99
+ # in place, with no copy of its bytes.
66
100
  def read_packed
67
101
  length = read_varint
68
- raise DecodeError, "truncated packed field" if @position + length > @limit
102
+ limit = @limit
103
+ raise DecodeError, "truncated field" if @position + length > limit
69
104
 
70
- reader = Reader.new(@buffer, @position, @position + length)
71
- @position += length
72
- reader
105
+ @limit = @position + length
106
+ result = yield self
107
+ @position = @limit
108
+ @limit = limit
109
+ result
110
+ end
111
+
112
+ # The same, for a value read by recursing into it — a nested message or
113
+ # a map entry — counting the nesting so deeply nested input raises
114
+ # rather than overflowing the VM stack. A packed field holds scalars
115
+ # and does not recurse, so it costs no level and reads through
116
+ # read_packed; the bounding is spelled out twice rather than delegated
117
+ # because every nested message pays for the call.
118
+ def read_nested
119
+ length = read_varint
120
+ limit = @limit
121
+ raise DecodeError, "truncated field" if @position + length > limit
122
+ raise DecodeError, "nested deeper than #{MAX_DEPTH}" if @depth >= MAX_DEPTH
123
+
124
+ @limit = @position + length
125
+ @depth += 1
126
+ result = yield self
127
+ @depth -= 1
128
+ @position = @limit
129
+ @limit = limit
130
+ result
73
131
  end
74
132
 
75
133
  # Skips one value of +wire_type+ and returns its raw bytes, so unknown
76
- # fields survive a decode/encode round trip.
77
- def skip(wire_type)
134
+ # fields survive a decode/encode round trip. +number+ is the field
135
+ # number the value arrived under: a group is closed by its own number
136
+ # and nothing else. Without one, any END_GROUP closes it.
137
+ def skip(wire_type, number = nil)
78
138
  start = @position
79
139
  case wire_type
80
140
  when Wire::VARINT then read_varint
81
141
  when Wire::FIXED64 then read_bytes(8)
82
142
  when Wire::LENGTH_DELIMITED then read_length_delimited
83
- when Wire::START_GROUP then skip_group
143
+ when Wire::START_GROUP then skip_group(number)
84
144
  when Wire::FIXED32 then read_bytes(4)
85
145
  else raise DecodeError, "unknown wire type #{wire_type}"
86
146
  end
@@ -89,14 +149,28 @@ module Fast
89
149
 
90
150
  private
91
151
 
92
- def skip_group
152
+ def read_byte
153
+ raise DecodeError, "truncated varint" if @position >= @limit
154
+
155
+ byte = @buffer.getbyte(@position)
156
+ @position += 1
157
+ byte
158
+ end
159
+
160
+ def skip_group(number)
161
+ raise DecodeError, "nested deeper than #{MAX_DEPTH}" if @depth >= MAX_DEPTH
162
+
163
+ @depth += 1
93
164
  loop do
94
- number, wire_type = read_tag
95
- return if wire_type == Wire::END_GROUP
96
- raise DecodeError, "invalid group" if number.zero?
165
+ inner, wire_type = read_tag
166
+ if wire_type == Wire::END_GROUP
167
+ raise DecodeError, "group #{number} closed by #{inner}" unless number.nil? || inner == number
97
168
 
98
- skip(wire_type)
169
+ break
170
+ end
171
+ skip(wire_type, inner)
99
172
  end
173
+ @depth -= 1
100
174
  end
101
175
  end
102
176
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  module Protowire
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -3,8 +3,9 @@
3
3
  module Fast
4
4
  module Protowire
5
5
  # The wire format itself: tags, varints, zigzag, fixed-width values and
6
- # length prefixes, appended to a binary String buffer. Everything above
7
- # this module is bookkeeping about which field gets which of these.
6
+ # length prefixes, appended to a binary String buffer, and the size each
7
+ # takes so a length prefix can be written before its payload. Everything
8
+ # above this module is bookkeeping about which field gets which of these.
8
9
  module Wire
9
10
  VARINT = 0
10
11
  FIXED64 = 1
@@ -14,10 +15,23 @@ module Fast
14
15
  FIXED32 = 5
15
16
 
16
17
  UINT64_MASK = (1 << 64) - 1
17
- private_constant :UINT64_MASK
18
+ # Zero bytes a length prefix is widened to, by varint width.
19
+ PLACEHOLDERS = Array.new(11) { |width| ("\0" * width).b.freeze }.freeze
20
+ private_constant :PLACEHOLDERS
18
21
 
19
22
  module_function
20
23
 
24
+ # A buffer written to here must be binary: appending a byte to a text
25
+ # String appends that encoding's character for it instead, silently
26
+ # writing something else. An empty buffer is simply retagged, so the
27
+ # usual ways of making one (+"", String.new("")) still work.
28
+ def binary_buffer(buffer)
29
+ return buffer if buffer.encoding == Encoding::BINARY
30
+ raise ArgumentError, "buffer must be a binary String, got #{buffer.encoding}" unless buffer.empty?
31
+
32
+ buffer.force_encoding(Encoding::BINARY)
33
+ end
34
+
21
35
  # The key for +number+ / +wire_type+ as frozen bytes.
22
36
  def tag(number, wire_type)
23
37
  varint((number << 3) | wire_type).freeze
@@ -38,34 +52,100 @@ module Fast
38
52
  buffer << value
39
53
  end
40
54
 
55
+ # Bytes append_varint writes for +value+.
56
+ def varint_size(value)
57
+ return 10 if value.negative?
58
+
59
+ size = 1
60
+ while value > 0x7f
61
+ value >>= 7
62
+ size += 1
63
+ end
64
+ size
65
+ end
66
+
67
+ # (* 2 rather than << 1: a left shift of a negative Fixnum allocates.)
41
68
  def zigzag32(value)
42
- (value << 1) ^ (value >> 31)
69
+ (value * 2) ^ (value >> 31)
43
70
  end
44
71
 
45
72
  def zigzag64(value)
46
- (value << 1) ^ (value >> 63)
73
+ (value * 2) ^ (value >> 63)
47
74
  end
48
75
 
49
76
  def unzigzag(value)
50
77
  (value >> 1) ^ -(value & 1)
51
78
  end
52
79
 
53
- # A length-delimited field: +tag+ then the varint size of +bytes+ then
54
- # +bytes+. Non-ASCII text is appended as its bytes so the binary buffer
55
- # never trips Ruby's encoding compatibility check.
56
- def append_length_delimited(buffer, tag, bytes)
80
+ # A length-delimited field whose payload the block appends to +buffer+
81
+ # itself, behind a length prefix filled in once the payload is there,
82
+ # so a nested message, packed field or map entry costs no buffer of
83
+ # its own. Returns the width of the prefix it wrote: a caller writing
84
+ # many alike values passes it back as +width+ and the prefix is
85
+ # rarely resized.
86
+ def append_length_delimited_from(buffer, tag, width = 1)
57
87
  buffer << tag
58
- append_varint(buffer, bytes.bytesize)
59
- buffer << (bytes.ascii_only? ? bytes : bytes.b)
88
+ start = reserve_length(buffer, width)
89
+ yield buffer
90
+ close_length(buffer, start, width)
91
+ end
92
+
93
+ # Appends +width+ placeholder bytes for a length prefix and returns the
94
+ # position after them, for close_length.
95
+ def reserve_length(buffer, width = 1)
96
+ buffer << PLACEHOLDERS[width]
97
+ buffer.bytesize
60
98
  end
61
99
 
62
- # A length-delimited field whose payload the block writes into a fresh
63
- # buffer. (Writing into the parent and inserting the size afterwards
64
- # looks cheaper but String#insert costs O(size) on the parent.)
65
- def append_length_delimited_from(buffer, tag)
66
- payload = String.new
67
- yield payload
68
- append_length_delimited(buffer, tag, payload)
100
+ # Writes the varint size of everything appended since +start+ into the
101
+ # +width+ placeholder bytes before it, first resizing the placeholder
102
+ # when the size needs a different width, which moves only the payload.
103
+ # Returns the width written. (Growing a String in place reallocates it
104
+ # to exactly the new size, so on a large buffer a resize costs far more
105
+ # than the bytes moved; hence the width hint.)
106
+ def close_length(buffer, start, width = 1)
107
+ length = buffer.bytesize - start
108
+ needed = length < 0x80 ? 1 : varint_size(length)
109
+ buffer.bytesplice(start - width, width, PLACEHOLDERS[needed]) unless needed == width
110
+ position = start - width
111
+ while length > 0x7f
112
+ buffer.setbyte(position, (length & 0x7f) | 0x80)
113
+ position += 1
114
+ length >>= 7
115
+ end
116
+ buffer.setbyte(position, length)
117
+ needed
118
+ end
119
+
120
+ # Text of any encoding goes into the binary buffer as bytes. Ruby 3.4's
121
+ # String#append_as_bytes does that with no copy, and takes the tag and
122
+ # a one-byte size in the same call; before it, non-ASCII text is
123
+ # appended as a binary copy so the buffer never trips Ruby's encoding
124
+ # compatibility check.
125
+ if String.method_defined?(:append_as_bytes)
126
+ # A length-delimited field: +tag+, the varint size of +bytes+, +bytes+.
127
+ def append_length_delimited(buffer, tag, bytes)
128
+ size = bytes.bytesize
129
+ return buffer.append_as_bytes(tag, size, bytes) if size < 0x80
130
+
131
+ buffer << tag
132
+ append_varint(buffer, size)
133
+ buffer.append_as_bytes(bytes)
134
+ end
135
+
136
+ def append_bytes(buffer, bytes)
137
+ buffer.append_as_bytes(bytes)
138
+ end
139
+ else
140
+ def append_length_delimited(buffer, tag, bytes)
141
+ buffer << tag
142
+ append_varint(buffer, bytes.bytesize)
143
+ append_bytes(buffer, bytes)
144
+ end
145
+
146
+ def append_bytes(buffer, bytes)
147
+ buffer << (bytes.encoding == Encoding::BINARY || bytes.ascii_only? ? bytes : bytes.b)
148
+ end
69
149
  end
70
150
  end
71
151
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-protowire
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -46,7 +46,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
46
  - !ruby/object:Gem::Version
47
47
  version: '0'
48
48
  requirements: []
49
- rubygems_version: 4.0.10
49
+ rubygems_version: 4.0.20
50
50
  specification_version: 4
51
51
  summary: 'Protocol Buffers wire format for Ruby: declare messages, encode and decode
52
52
  bytes, no runtime.'