Repository navigation
Visitor/SAX-style parse API #70
SeanTAllen
started this conversation in
Research
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
The problem
The streaming decoder returns a
DecodeResultunion for eachvalue. The caller uses
matchto dispatch on the decoded type.This works well when every value in the stream matters, but it
has two costs:
Intermediate objects for every value. Each decoded value
becomes a Pony object -- a
String val, anArray[U8] val,a
MessagePackTimestamp, etc. For callers that want toextract a few fields from a large message, or transform
msgpack directly to another format, most of these objects are
created only to be immediately discarded.
Explicit container management. When the decoder returns a
MessagePackArrayorMessagePackMapheader, the caller isresponsible for tracking how many elements remain to be read
and managing the nesting. For deeply nested structures, this
means manually maintaining a stack of element counts.
A visitor API inverts the control flow. Instead of the caller
pulling values and dispatching on type, the parser drives the
traversal and calls methods on a visitor object as it encounters
each element. The visitor implements only the callbacks it cares
about; unneeded values are never materialized into Pony objects.
Use cases
Selective field extraction. A message contains a large map
with dozens of keys. The caller needs only two of them. With the
current API, the caller must decode every key and value
(or use
skipto advance past unwanted values) to traversethe map. With a visitor, the parser handles the traversal and
calls
visit_strfor each key -- the visitor checks if it's akey of interest and sets a flag to capture the next value.
Format transcoding. Converting msgpack directly to JSON,
CBOR, or another wire format. The visitor's callbacks map
directly to the output format's write operations
(
visit_nilwritesnull,start_arraywrites[,visit_strwrites a quoted string, etc.) with no intermediaterepresentation.
Validation and metrics. Scanning a message to check
structure (correct nesting, expected types in expected
positions) or to collect statistics (count of each type,
maximum nesting depth, total string bytes) without building
any data structures.
Streaming transformation. Processing a stream of msgpack
messages where each message is transformed or filtered and
forwarded. The visitor can write directly to an output buffer
during traversal.
How other libraries handle this
msgpack-cxx (C++)
The C++ library provides a full visitor interface driven by
msgpack::parse(). The visitor is a class satisfying a concept(duck-typed template) with methods for each msgpack type. A
msgpack::null_visitorbase class provides defaultimplementations that return
true(continue parsing) for allmethods.
Leaf value callbacks:
Container callbacks have start/end pairs with item-level
hooks for maps:
Error callbacks:
Every
visit_*/start_*/end_*method returnsbool. Theparse function checks this return value after each callback --
returning
falsefrom any callback aborts parsing immediately.The parse function itself returns
trueif parsing completedsuccessfully,
falseotherwise.Key design points: the parser owns the read loop and drives
all recursion into containers. The visitor never reads from the
buffer directly. Container callbacks receive the element count
up front (
start_array(num_elements)) so the visitor canpre-allocate if desired.
Serde (Rust)
Serde's
Deserializer/Visitorsplit is the most influentialvisitor-based deserialization design in modern use. The
Visitortrait has methods for each data type, all withdefault implementations that return a type error:
Default implementations forward smaller integer types to
larger ones (e.g.,
visit_i8callsvisit_i64), soimplementors only need to handle the widest types. The
expecting()method provides error messages.Key difference from msgpack-cxx: Serde's visitor produces a
value (
Self::Value), making it a construction-orientedpattern. The callbacks for sequences and maps receive iterator
objects (
SeqAccess,MapAccess) that the visitor pulls from,rather than receiving push-style start/end notifications. This
pull-within-push hybrid gives the visitor control over
iteration within each container.
Jackson (Java)
Jackson's
JsonParseris a pull-based streaming API ratherthan a visitor, but it solves the same problem -- processing
serialized data without building a tree. The caller advances
a cursor with
nextToken()and dispatches on the token type:skipChildren()is the skip equivalent for containers --it advances past the current array or object and all its
contents without materializing them.
Jackson's approach is simpler to implement (no visitor trait,
just a token stream) but puts the traversal burden on the
caller. Every consumer must handle the token loop, nesting,
and error recovery. The visitor pattern inverts this: the
parser handles traversal and the consumer handles only the
values it cares about.
Common patterns across libraries
All of these designs share a few properties:
interact with the underlying byte buffer.
return value or exception) to abort parsing without reading
the rest of the data.
implement either succeed silently (msgpack-cxx) or fail with
a type error (Serde). The choice depends on whether the
pattern is "process what you understand" (no-ops) or
"construct a specific type" (errors).
arrays and maps, with the element count available at
start_*time.Design
Visitor trait
A Pony trait with default no-op implementations. Callers
implement only the callbacks they need. All methods use a
refreceiver so the visitor can accumulate state.Each callback returns
Bool--trueto continue parsing,falseto abort. This matches msgpack-cxx's approach and isthe right default for Pony: the "process what you understand,
skip what you don't" model suits a wire format library where
forward compatibility matters.
Design decisions in the trait
Unified integer callbacks (
visit_uint/visit_int) insteadof per-width callbacks. The msgpack wire format encodes
integers in multiple widths (fixint, uint_8, uint_16, ...) but
these are transparent size optimizations. A visitor extracting a
"count" field doesn't care whether the sender used
uint_8oruint_32. UsingU64andI64collapses the format familiesinto the values they represent, matching how
MessagePackDecoder.uintandMessagePackDecoder.intalreadywork.
If a caller needs the original wire format, they should use the
lower-level decoder API directly. The visitor pattern is for
callers who care about values, not encodings.
Separate
visit_float_32/visit_float_64instead of aunified float callback. Unlike integers where smaller formats
are strict subsets of larger ones,
F32andF64havedifferent precision. Collapsing
F32intoF64would silentlychange the precision semantics. Keeping them separate lets the
visitor handle each precision explicitly.
visit_strreceivesString val, not raw bytes and alength. The C++ library passes
(const char*, uint32_t)forzero-copy access to the buffer. In Pony,
MessagePackDecoderproduces
String iso^from the reader -- the data is copiedout of the reader's internal buffer into an
isoarray, thenString.from_iso_arraytakes ownership without a second copy.Passing a
String valto the visitor is idiomatic Pony.For true zero-copy semantics,
MessagePackZeroCopyDecoderproducesString valviewsdirectly into the reader's buffer (when the data falls within
a single chunk; it falls back to copying when data spans
chunk boundaries). A visitor parser built on the zero-copy
decoder would avoid allocation for most string values
(see "Relationship to existing APIs" below).
Explicit
visit_timestamp. Timestamps are semanticallydistinct from extensions even though they are wire-encoded as
ext type -1. The streaming decoder already decodes them into
MessagePackTimestamprather thanMessagePackExt. The visitorfollows the same principle -- timestamps get their own callback
so the visitor doesn't need to check for ext type 0xFF
manually.
visit_extdoes not receive timestamps. When the parserencounters an ext value with type 0xFF (timestamp), it calls
visit_timestamp, notvisit_ext. This is consistent withthe streaming decoder's behavior and the msgpack spec's
treatment of timestamps as a distinct type.
Map item callbacks (
start_map_key/end_map_key/start_map_value/end_map_value). These bracket each keyand value within a map traversal, following msgpack-cxx's
design. They let the visitor know whether the next value is
a key or a value without maintaining a toggle flag. The
sequence for each map entry is:
Error callbacks return
None, notBool. Once an erroroccurs, parsing cannot continue -- the stream is in an
indeterminate state. The callbacks are informational. The
parse function returns
falseafter calling them regardlessof what the visitor does.
The parse function
A standalone function (or method on a new primitive) that
drives the traversal:
Who drives the read loop
The parse function owns the reader and drives all reads.
The visitor never sees the reader. This is a deliberate
constraint:
reading, and container recursion.
decoded values and makes decisions (continue or abort).
This means the visitor cannot do things like "read the next
3 values manually" -- it must accept them as they arrive
through callbacks. If a caller needs that level of control,
the lower-level
MessagePackDecoderorMessagePackStreamingDecoderAPIs are the right choice.Error handling and early termination
Two mechanisms for stopping the parse:
Visitor returns
false. The parse function checks thereturn value after every callback. When any callback returns
false, the parse function returnsfalseimmediatelywithout reading further. This is the clean abort path --
the visitor has seen enough and wants to stop. The reader
is left positioned after the last fully-read value. For
non-streaming use, the caller can discard the reader. For
streaming use, the reader's position is indeterminate within
the current top-level value.
Read error (partial). If the reader doesn't have enough
data,
MessagePackDecodermethods raiseerror, whichpropagates through
_parse_value. The parse function catchesthis at the top level, calls
visitor.insufficient_data(),and returns
false. The reader's state may be corrupted(same limitation as
MessagePackDecoder-- see issue #14).Invalid format byte. If the format byte is 0xC1 (the
only invalid byte in msgpack), the parser calls
visitor.parse_error()and returnsfalse.Relationship to existing APIs
The visitor parser builds on
MessagePackDecoder. It uses thedecoder's methods to read values from the reader and adds the
traversal logic (container recursion, visitor callbacks) on
top. It does not replace or modify any existing API.
A zero-copy variant could be built on
MessagePackZeroCopyDecoderandZeroCopyReaderinstead.The zero-copy decoder returns
String valandArray[U8] valviews into the reader's buffer when therequested range falls within a single internal chunk,
avoiding allocation entirely. When data spans chunk
boundaries, it falls back to copying. This would be a
natural fit for visitors that transform or forward data
without retaining it, since the decoded values can be
released as soon as the callback returns. The tradeoff is
that decoded values pin the reader's underlying chunks in
memory until they are collected -- the same tradeoff
documented in the zero-copy decoder's own API.
The visitor parser does not build on
MessagePackStreamingDecoder. The streaming decoder'speek-before-consume contract adds overhead that the visitor
doesn't need -- if the caller has all the data available (the
same assumption
MessagePackDecodermakes), the visitor canread directly.
The existing
skipmethod onMessagePackDecoderserves arelated but distinct purpose.
skipadvances past a valuewithout decoding it, which is useful when the caller knows
it doesn't need a value. The visitor parser handles
skipping differently: values the visitor doesn't care about
are still decoded (to advance the reader), but the visitor's
no-op default callbacks discard them without the caller
needing to write any code. For callers that want to avoid
even the decoding cost, a future optimization could have the
parse function call
skipwhen it can determine the visitorhas no interest in a value, but this would require the
visitor to signal disinterest before the value is read
(a more complex protocol than simple
Boolreturns).Streaming-safe visitor parsing
A streaming-safe variant could be built on
MessagePackStreamingDecoderin the future. The streamingdecoder now provides the building blocks this would need:
peek-before-consume semantics that return
NotEnoughDatawithout corrupting the reader, configurable size and depth
limits via
MessagePackDecodeLimits, askipmethod withits own
max_skip_valueslimit, automatic container depthtracking, and opt-in UTF-8 validation.
The main challenge remains the parse function's recursion
stack. A non-streaming visitor parser uses the Pony call
stack to track its position within nested containers
(
_parse_arraycalls_parse_valuewhich calls_parse_map, etc.). When the streaming decoder returnsNotEnoughData, the parse function must save this entirerecursion state so it can resume after more data arrives.
This requires converting the implicit call stack into an
explicit state machine -- a container stack tracking
(container type, total count, elements processed) at each
nesting level.
The streaming decoder's existing depth tracking and limit
enforcement would carry over directly, but the visitor
parser would need its own resumable traversal logic on top.
A streaming-safe visitor would also need additional error
callbacks beyond
parse_errorandinsufficient_data:InvalidUtf8(returned by the streaming decoder whenUTF-8 validation is enabled) and
LimitExceeded(returnedwhen size or depth limits are exceeded, or when
skipexceeds
max_skip_values). This is deferred as futurework.
Capability considerations
The visitor trait uses
refreceiver on all methods. This isthe right choice for stateful visitors that accumulate results
(extracted fields, transcoded output, statistics). A
boxreceiver would prevent the visitor from storing state, which
defeats the purpose for most use cases.
The parse function takes
MessagePackVisitor ref, notisoor
val. Since the parser and visitor operate within the sameactor's context (the parser reads from a
Reader ref, whichis already non-sendable),
refis the natural capability.Example: selective field extraction
Extracting
nameandagefrom a msgpack map that maycontain other fields:
Usage:
Example: msgpack-to-JSON transcoding
Converting a msgpack value to a JSON string:
Note: the JSON transcoder example omits string escaping
for brevity. A production implementation would need to
escape control characters, quotes, and backslashes in
visit_str.Scope
This proposal covers:
MessagePackVisitortrait with default no-op methodsfor all msgpack value types and container boundaries.
MessagePackVisitorParserprimitive with aparsemethod that drives the traversal.
available, same as
MessagePackDecoder).This proposal does not cover:
NotEnoughData). This requires a state machine to saveand restore the recursion stack and is deferred as
future work.
MessagePackVisitorEncoderthat accepts visitor-style calls and writes msgpack).
This is the inverse pattern and may be useful for format
transcoding but is a separate design.
MessagePackZeroCopyDecoderand
ZeroCopyReader. The design would be structurallyidentical to the
MessagePackDecoder-based parser, withdifferent reader and decoder types. Worth considering if
the visitor parser proves useful and allocation
overhead matters in practice.
MessagePackDecoder.skip. The visitor'sno-op default callbacks already handle unwanted values
without caller effort. A future optimization could use
skipto avoid decoding values the visitor provablyignores, but this would require a richer signaling
protocol than the current
Boolreturns (see"Relationship to existing APIs").
All reactions