docs: propose backward-compatible typed per-message fields - #1752
docs: propose backward-compatible typed per-message fields#1752amacneil wants to merge 6 commits into
Conversation
Co-authored-by: Adrian Macneil <adrian@foxglove.dev>
Co-authored-by: Adrian Macneil <adrian@foxglove.dev>
There was a problem hiding this comment.
Draft proposal — flagging design gaps now so they don't bake in. Cross-cutting items to resolve before this is implementable:
- Index-record layout (inline):
message_index_lengthsemantics break down under "interleaved" placement, for both the standard Chunk Index and the Auxiliary Chunk Index. Needs a deterministic byte layout. - Summary self-sufficiency (inline ×2): seeking by name from the summary alone needs the Timestamp Name registry to be in the summary, and needs names to be unique — both are currently optional/unspecified.
Minor, on the PR description: the overhead math looks light. Each timestamp entry is 10 bytes (uint16 id + uint64 value), not 8, plus a one-time 4-byte array length prefix — so a one-aux-timestamp message is ~25 raw bytes, not ~17–19. Doesn't change the "compresses to nearly nothing" conclusion, but worth correcting.
|
|
||
| An Auxiliary Message Index record allows readers to locate individual Message records within a chunk by an auxiliary timestamp, analogous to the [Message Index](#message-index-op0x07) record for `log_time`. | ||
|
|
||
| A sequence of Auxiliary Message Index records may occur immediately after a chunk, interleaved with that chunk's Message Index records. At most one Auxiliary Message Index record exists per (channel, timestamp ID) combination for which messages in the chunk carry that auxiliary timestamp. |
There was a problem hiding this comment.
interleaved with that chunk's Message Index records makes message_index_length ambiguous in two places.
The standard Chunk Index defines message_index_length as the total byte length of the message index records after the chunk, and some readers read that whole span in one shot (e.g. the Rust CLI's remote range reads). If 0x12 records are interleaved, does that length now include them or not?
And the Auxiliary Chunk Index's own message_index_length is "total length ... for this timestamp ID" — with multiple timestamp IDs interleaved, the records for a given ID are no longer contiguous, so a single length can't describe them.
Either mandate a deterministic layout (e.g. all standard Message Index records contiguous, then each timestamp ID's aux index records contiguous) and define each length over its own block, or spell out exactly how a reader walks the interleaving. As written it's underspecified.
| | 2 | id | uint16 | A unique identifier for this auxiliary timestamp within the file. Must not be zero. | | ||
| | 4 + N | name | String | A human-readable name for the timestamp, e.g. `publish_time`, `sensor_time`. See [well-known timestamp names][timestamp_names]. | | ||
|
|
||
| Timestamp Name records may be duplicated in the summary section. A Timestamp Name record with an id of zero is invalid and should be ignored by readers. Readers that do not support auxiliary timestamps will skip this record. |
There was a problem hiding this comment.
Timestamp Name in the summary is optional here ("may be duplicated"), but indexed seeking needs it. Compare the existing Chunk Index rule: "A Schema and Channel record MUST exist in the summary section for all messages in chunks that are indexed by Chunk Index records." Same logic applies — if Auxiliary Chunk Index records (and auxiliary_message_time_bounds in Statistics, which is keyed by ID) are present in the summary, a reader doing a summary-only seek can't resolve a name → ID without falling back to a data-section scan, defeating the point of the index. Should this be a MUST-duplicate-in-summary when aux indexes exist?
|
|
||
| To seek to messages by an auxiliary timestamp `T` (rather than `log_time`): | ||
|
|
||
| 1. Read the summary section and collect the Timestamp Name records to map the desired name to its ID, plus the Auxiliary Chunk Index records for that ID. |
There was a problem hiding this comment.
map the desired name to its ID assumes names are unique, but the spec only requires IDs to be unique (registry.md: "Names are free-form"). Two Timestamp Name records with different IDs and the same name are legal today, which makes name → ID a one-to-many lookup and this step ambiguous. Either require names to be unique within a file, or define how a reader resolves a name that maps to multiple IDs.
|
|
||
| | Bytes | Name | Type | Description | | ||
| | ----- | ---------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | 2 | channel_id | uint16 | Channel ID. Must match the `channel_id` of the immediately preceding Message record. | |
There was a problem hiding this comment.
Given strict adjacency to a Message that already carries channel_id, what does repeating it here buy beyond a redundant validation check? The PR description calls this an "optional channel_id", but the table marks it required with a MUST-match rule — reconcile the two. If it's purely a consistency guard, say so and own the 2 bytes/message; if it's meant to be omittable, the layout and parsing need to reflect that.
| ### Writing considerations | ||
|
|
||
| - Because association is positional, a Message Auxiliary Timestamps record MUST be written immediately after its Message, with no records in between, in the same record stream. Tools that copy records through verbatim preserve this pairing; tools that re-emit messages via a message-level API must be updated to carry the auxiliary records along. | ||
| - Declaring timestamp IDs per file (and listing them where convenient, e.g. in channel metadata) keeps `merge` operations cheap: messages from channels without a given auxiliary timestamp simply omit it rather than padding with zero values. |
There was a problem hiding this comment.
keeps merge operations cheap — does it, really? On merge, timestamp IDs collide across inputs (file A's ID 1 ≠ file B's ID 1), exactly like schema/channel IDs do today. Resolving that means remapping IDs inside every Message Auxiliary Timestamps record, plus both aux index records and the Statistics bounds — which is at odds with the "tools that copy records through verbatim preserve this pairing" point right above. Worth calling out the remap cost instead of implying merge is free.
| | 4 + N | channel_message_counts | `Map<uint16, uint64>` | Mapping from channel ID to total message count for the channel. An empty map indicates this statistic is not available. | | ||
| | 4 + N | auxiliary_message_time_bounds | `Map<uint16, Tuple<Timestamp, Timestamp>>` | Optional. Mapping from auxiliary timestamp ID to the (earliest, latest) value of that timestamp across all messages in the file. An empty map indicates this statistic is not available. | | ||
|
|
||
| The `auxiliary_message_time_bounds` field is an optional backward-compatible extension appended after `channel_message_counts`. Readers that predate auxiliary timestamp support read the preceding fields and ignore the trailing bytes. Writers that do not record auxiliary timestamps may omit the field entirely. |
There was a problem hiding this comment.
Confirmed the Go reader bounds its read by the channel_message_counts length prefix and ignores trailing bytes (ParseStatistics in go/mcap/parse.go), so this is compatible there. Worth confirming the same for TS/Python/Rust/C++/Swift before relying on it — a stricter reader that validates record length against parsed fields would reject the extra bytes. Also worth a one-line note on why Statistics is extended in place here, when the rest of the proposal deliberately avoids touching frozen records and adds new opcodes instead.
Generalize the auxiliary-timestamps proposal into named, typed per-message fields, covering both additional timestamps and arbitrary per-message metadata (discussion #1369) with one mechanism. Fields are declared in a Field record (id, name, encoding, length, flags) and carried adjacent to their message in a Message Fields record. Indexed fields get Field Index / Field Chunk Index records. Encoding uses a physical (length) + logical (encoding string) split with Rust-style, Arrow-aligned scalar names. Co-authored-by: Adrian Macneil <adrian@foxglove.dev>
There was a problem hiding this comment.
This rework renamed the records (Timestamp Name → Field, Message Auxiliary Timestamps → Message Fields, Auxiliary Message/Chunk Index → Field Index / Field Chunk Index) but kept the structure identical, so every concern from my prior review still applies to the renamed records. Those threads now show as "outdated" but remain unaddressed: the message_index_length ambiguity when Field Index records interleave with Message Index records after a chunk; Field records being only optionally duplicated in the summary (an indexed name→ID seek needs them in the summary, cf. the existing Schema/Channel MUST rule for Chunk Index); name→ID resolution assuming unique names while the registry still says names are free-form; the redundant channel_id in Message Fields; recommending a publish_time field name that collides with the Message's built-in publish_time; the unstated ID-remap cost on merge; and extending the frozen Statistics record in place rather than via a new opcode. Worth working back through those before this moves past draft.
New for the typed design: see the inline note on Field Chunk Index min_value/max_value.
| | 8 | min_value | uint64 | Minimum value of this field among messages in the chunk, interpreted per its encoding. Zero if no messages in the chunk carry it. | | ||
| | 8 | max_value | uint64 | Maximum value of this field among messages in the chunk, interpreted per its encoding. Zero if no messages in the chunk carry it. | |
There was a problem hiding this comment.
min_value/max_value are typed uint64, but indexed encodings now include int64 and float64 — two things bake in here:
-
Ordering. Pruning compares the query against
[min_value, max_value]. Treating these as rawuint64(as the type column says) gives wrong results forint64(negative two's-complement sorts above positives) andfloat64(sign bit + bit-pattern order ≠ numeric order). The Field Indexrecordsvalue column and the Statisticsfield_value_boundsshare the trap. Either state explicitly that min/max and all comparisons use the field's logical type ordering — and define whatfloat64NaN /±0do — or restrict indexing touint64/timestamp. -
Zero sentinel. "Zero if no messages in the chunk carry it" collides with a legitimate value of
0(common for int/float, unlikelog_time). A chunk with no field-carrying messages is then indistinguishable from one whose values are all0, so avalue == 0query can't prune it. If a Field Chunk Index is emitted per (chunk, indexed field) regardless of presence, add an explicit presence/count signal rather than overloading0.
Set Message publish_time / Attachment create_time to a reserved sentinel (0xFFFFFFFFFFFFFFFF) whenever a Message Fields / Attachment Fields record follows. The sentinel is an in-band signal (readers only look for fields when set) and an integrity check: a sentinel with no following fields record indicates the records were dropped by an old writer during a round-trip, and readers should warn. Adds the Attachment Fields (0x14) record so attachments can carry fields too. Co-authored-by: Adrian Macneil <adrian@foxglove.dev>
There was a problem hiding this comment.
Reviewing the field-sentinel commit (7314361) on top of my last pass.
The sentinel is a clever presence/integrity signal, but it flips the feature from purely additive to mutating a populated built-in field (publish_time/create_time). That tradeoff is the root of both inline gaps: a real publish_time can be lost the moment you attach any unrelated field, and the Attachment Index's duplicated create_time now has undefined meaning under the sentinel. Worth deciding whether mutating the built-in is acceptable vs. signaling presence out of band before this moves past draft.
The structural concerns from my prior passes (index interleaving layout, summary self-sufficiency for name→ID, free-form name uniqueness, merge ID-remap cost, extending the frozen Statistics record in place, and the int64/float64 min/max ordering + zero-sentinel) remain open in their threads — the rename to typed fields kept the underlying structure, so they still apply.
|
|
||
| A message may carry additional named, typed values (such as extra timestamps or per-message metadata) by following it with a [Message Fields](#message-fields-op0x11) record. The Message record itself is never extended, so readers that predate the Message Fields record continue to read `log_time` and `publish_time` unchanged. | ||
|
|
||
| When a Message Fields record is present, the writer MUST set the message's `publish_time` to the [field sentinel](#field-sentinel) `0xFFFFFFFFFFFFFFFF`. The message's true publish time, if any, is then carried as a `publish_time` field within the Message Fields record; if no such field is present, the publish time is unavailable and readers should fall back to `log_time`. |
There was a problem hiding this comment.
The sentinel turns Message Fields from purely additive into something that mutates publish_time — and the salvage is only "if any," not a MUST. So a writer that attaches unrelated metadata (say a Zenoh source_sn) to a message whose publish_time ≠ log_time now MUST also re-encode the original publish_time as a field, or it's silently downgraded to log_time. The previous design didn't have this footgun: fields were extra, publish_time stayed put.
Two ways out: require it ("a writer that sets the sentinel and had a real, non-sentinel publish_time MUST carry it as a publish_time field"), or reconsider signaling presence out of band (e.g. a Message flag bit / separate signal) instead of clobbering a populated field. As written, the act of adding any field can lose a real timestamp. 🤔
| | 8 + N | data | uint64 length-prefixed Bytes | Attachment data. | | ||
| | 4 | crc | uint32 | CRC32 checksum of preceding fields in the record. A value of zero indicates that CRC validation should not be performed. | | ||
|
|
||
| An attachment may carry additional named, typed values by following it with an [Attachment Fields](#attachment-fields-op0x14) record. When one is present, the writer MUST set the attachment's `create_time` to the [field sentinel](#field-sentinel) `0xFFFFFFFFFFFFFFFF`; the true create time, if any, is then carried as a `create_time` field within the Attachment Fields record. |
There was a problem hiding this comment.
Extending the sentinel to attachments collides with the Attachment Index (op=0x0A), which duplicates create_time (line 290). When an attachment carries fields, its record create_time is the sentinel — what does the index copy hold?
- If it mirrors the record (sentinel), a summary-only attachment listing shows year-2554 for every attachment that carries fields, and the real
create_timelives in the Attachment Fields record, which isn't in the summary — so it's unrecoverable without scanning the data section. - If it holds the real value, it diverges from the record and breaks the "sentinel ⇒ fields follow" contract that holds everywhere else.
Messages dodge this because Message Index keys only on log_time. Attachments don't, so the spec needs to say which value the index carries (and ideally how a summary reader recovers the true create_time).
Co-authored-by: Adrian Macneil <adrian@foxglove.dev>
Remove the flags/indexed byte from the Field record. A field is indexed if and only if Field Index / Field Chunk Index records are present for it, exactly as log_time indexing is signaled by Message Index / Chunk Index records. Indexing remains an optional, per-field writer choice, restricted to fixed-width 64-bit orderable encodings. Co-authored-by: Adrian Macneil <adrian@foxglove.dev>
There was a problem hiding this comment.
Only delta since my last pass (107db43) is dropping the indexed flag — see the inline note on why making indexing implicit needs an explicit per-chunk completeness invariant or field seeks go unsound. The structural threads from earlier passes are still open and unaddressed; worth working through them before this leaves draft.
|
|
||
| The `length` byte is the physical descriptor used to parse a value; the `encoding` is the logical descriptor used to interpret it. A reader can therefore skip a field whose `encoding` it does not recognize, as long as it has the Field record (which gives the length). For well-known encodings, `length` must be consistent with the encoding (e.g. `timestamp` and `uint64` must use `length = 8`). | ||
|
|
||
| A field may be _indexed_ for fast seeking by writing [Field Index](#field-index-op0x12) and [Field Chunk Index](#field-chunk-index-op0x13) records for it, exactly as `log_time` is indexed by [Message Index](#message-index-op0x07) and [Chunk Index](#chunk-index-op0x08) records. There is no flag in the Field record itself: as with `log_time`, a field is indexed if and only if those index records are present. Indexing is therefore an optional, per-field choice made by the writer. Only fields with a fixed-width 64-bit orderable encoding (`timestamp`, `uint64`, `int64`, `float64`; i.e. `length == 8`) may be indexed. |
There was a problem hiding this comment.
Dropping the flag removes the only file-global "field F is indexed" signal — indexed-ness is now defined purely by the presence of index records, which is per-(chunk, field) and circular (line 401: "every (chunk, indexed field ID) combination that is indexed"). Nothing then requires that every chunk carrying F has a Field Chunk Index for it.
A summary-only seek (notes.md "Seeking by a field value") collects the Field Chunk Index records for F and prunes by them — so any chunk that carries F but lacks a Field Chunk Index for it is invisible to the seek, and its messages are silently missed. log_time can't hit this because Chunk Index is mandatory for every chunk in an indexed file; with the flag gone, F has no equivalent guarantee.
State the invariant explicitly: if any Field Chunk Index exists for F, then every chunk containing a message that carries F MUST have one (and the corresponding Field Index). Otherwise index-based field seeks are unsound. 🤔
Goals
This is a docs-only draft spec proposal to let an MCAP
Message(andAttachment) carry an arbitrary number of additional named, typed values — without breaking existing readers or writers. It unifies two long-standing asks under one mechanism:Messagehas exactlylog_time+publish_time, and onlylog_timeis indexable, so seeking/playing back by publish time requires a full scan (see discussion #1542, and the closed PR #1196). We want extra timestamps (e.g.publish_time,sensor_time) that are optionally indexed and seekable.Samplehasattachment,priority,congestion_control,reliability,express,source_info(discussion #1369). Storing per-message-varying values today forces either a channel explosion (channel metadata) or re-wrapping an already-encoded payload (schema change + extra copy). We want a place for per-message metadata that avoids both.Additional goals that shaped the design:
float64, notdouble).Why this can be backward compatible
The
Messagerecord is frozen — its trailingdatafield has no length prefix, so nothing can be added before or after it within the record. Instead, all new data lives in records with new opcodes, which existing readers already skip (default: continue // skip unrecognized opcodesin Go;parseUnknownin TypeScript). Old readers keep readinglog_time/publish_time; new readers pick up the fields.Design
Five additive records (opcodes
0x10–0x14) plus one optionalStatisticsfield:0x10id(uint16),name,encoding(logical type),length(physical width). Written like Schema/Channel; duplicatable in the summary.0x11(field_id, value)pairs for the message it immediately follows (positional adjacency).0x120x13[min,max]value bounds + index locations for an indexed field (mirrors Chunk Index), for chunk pruning.0x14Attachment(reuses the same Field declarations; not indexed).Statisticsgains an optional trailingfield_value_boundsmap (file-global per-indexed-field bounds).Field sentinel (signal + integrity check)
When a Message has a Message Fields record, the writer MUST set the message's
publish_timeto the reserved sentinel0xFFFFFFFFFFFFFFFF(and an Attachment with an Attachment Fields record setscreate_timelikewise). This rides in a field that existing writers preserve, while the fields record itself rides in a record they may drop. So:publish_time/create_time, if any, is carried as a field; otherwise readers fall back tolog_timeas today. Old readers see the sentinel as a literal (year-2554) timestamp.Type system: physical
length+ logicalencodingFollowing the physical/logical split common to Parquet/Avro/Arrow:
lengthbyte = physical: high bit set ⇒ variable-length (uint32 length prefix per value); otherwise the low 7 bits are a fixed byte width. This alone lets a reader parse/skip any value, even one whoseencodingit has never seen (forward-compatible to future types).encodingstring = logical: a concise, Arrow-aligned scalar set with Rust-style names —bool,int8/16/32/64,uint8/16/32/64,float16/32/64,timestamp(uint64 ns, matcheslog_time),string,bytes. Deliberately scalar-only: composite data belongs in the payload + schema. Structuredbytesmay reuse MCAP's existing well-known message encodings (json,protobuf, …).A field entry is
<uint16 field_id><value>; values are little-endian.Indexing (implicit, like
log_time)There is no
indexedflag. A field is indexed if and only ifField Index/Field Chunk Indexrecords are present for it — exactly howlog_timeindexing is signaled by the presence ofMessage Index/Chunk Indexrecords. Indexing is thus an optional, per-field choice the writer makes by emitting (or not emitting) the index records. Only fixed-width 64-bit orderable encodings (timestamp,uint64,int64,float64) may be indexed.Key choices
schema_id/channel_id.log_time, fields likepublish_timemay not be monotonic, so chunk ranges can overlap and prune less effectively.Mapping the use cases
Field{name:"publish_time", encoding:"timestamp", length:8}+ write Field Index / Field Chunk Index records → seekable.attachment→bytes; per-messagesource_sn→uint32; constant-per-publisher QoS (priority,reliability, …) → channel metadata.Changes
website/docs/spec/index.md: five new record definitions; the field-sentinel section; data/summary allowed-records lists; Message/Attachment/Chunk/Statistics notes.website/docs/spec/registry.md: new "Field encodings" section (scalar type registry + recommended field names).website/docs/spec/notes.md: implementation notes for reading, seeking, and the sentinel.website/docs/spec/mcap.ksy: Kaitai definitions for the new records (theMessage Fields/Attachment Fieldsvalue layout is registry-dependent, so itsfieldsblob is left unexpanded in the Kaitai model, with a doc note).cspell.config.yaml: addseekable.This is docs-only — no library code is implemented yet. It captures the design for discussion.
Testing
yarn workspace website build— Docusaurus build succeeds withonBrokenLinks: "throw"; verified generated anchors (field-op0x10,message-fields-op0x11,field-index-op0x12,field-chunk-index-op0x13,attachment-fields-op0x14,field-sentinel,field-encodings) match all cross-links.prettier --check— passes on all edited spec files.cspell— passes on all edited spec files.python3 -c "import yaml; yaml.safe_load(...)"—mcap.ksyparses as valid YAML (no Kaitai compiler available in the environment).