Struct RecordRef
- Namespace
- DatabentoDotNet.Dbn
- Assembly
- DatabentoDotNet.Dbn.dll
A non-owning, runtime-polymorphic view over one DBN record that still sits in the decoder's read buffer. Downcast it to a concrete record struct with Has<T>() plus Get<T>(), or with TryGet<T>(out T).
public readonly ref struct RecordRef
- Inherited Members
- Extension Methods
Examples
while (decoder.TryNextRecord(out RecordRef record))
{
// The header is common to every record and needs no downcast.
Console.WriteLine($"{record.Header.RType} instrument {record.Header.InstrumentId}");
// TryGet checks the rtype first and reinterprets in place: a bounds check, not a copy.
if (record.TryGet(out TradeMsg trade))
{
Console.WriteLine($"{DbnTime.ToInstant(trade.IndexTs)} {trade.Price} x {trade.Size}");
}
}
The lifetime rule is the compiler's, not a convention. Neither of these builds, and that is the point — a record that escaped the loop would be reading bytes the decoder has since reused:
var seen = new List<RecordRef>(); // CS0306: a ref struct may not be a type argument
while (client.TryNextRecord(out var record))
{
await Task.Yield(); // CS4007: a ref struct cannot live across an await
}
Copy it onto the heap with CopyOf(RecordRef) when a record genuinely has to outlive the loop, and pay the two allocations knowingly.
Remarks
The port of upstream's RecordRef (record_ref.rs). Upstream stores a raw
NonNull<RecordHeader> plus a PhantomData lifetime and marks the
constructor unsafe; the .NET equivalent of that lifetime is a
ReadOnlySpan<T> field in a ref struct, which
the compiler enforces rather than the programmer promising. A ref struct cannot be
boxed, stored in a field, captured by a lambda, or carried across an await — which is
exactly the set of things that would let a record outlive the buffer bytes it points at.
That restriction is the design, not a limitation. The whole reason this codec exists is
that records are reinterpreted in place over the read buffer rather than copied out of it, and
the decoder is free to overwrite or move those bytes on the very next call made on it — see
the remarks on DbnFsm for exactly which calls end a record's life. The async I/O
layer therefore sits above the decoder and calls Fill itself; nothing here is
or becomes async.
Alignment is a precondition. Records are reinterpreted with
AsRef<T>(ReadOnlySpan<byte>), so the buffer a record is read from
must start 8-byte aligned. AlignedBuffer guarantees that for everything the decoder
produces; a caller constructing a RecordRef over its own storage must too.
Upstream pins the same requirement with a debug_assert_eq! on
align_offset (record_ref.rs:109-114); the Assert(bool, string)
below is its direct counterpart and, like upstream's, costs nothing in a release build.
Every multi-byte field read through this type is little-endian, because the reinterpret is raw. .NET has no supported big-endian target, so no byte swapping is done; on a hypothetical big-endian host every numeric field would be wrong.
Constructors
RecordRef(ReadOnlySpan<byte>, bool)
Wraps the record that begins at the start of buffer.
public RecordRef(ReadOnlySpan<byte> buffer, bool hasTsOut = false)
Parameters
bufferReadOnlySpan<byte>Bytes starting at a RecordHeader and at least as long as the length that header declares. Anything past the record is ignored, so passing the whole read buffer is fine. Must start 8-byte aligned — see the remarks on RecordRef.
hasTsOutboolWhether the stream appends an 8-byte
ts_outsend timestamp to every record. This is a property of the stream, stated once in the DBN metadata, not something recoverable from the record's own bytes — which is why it has to be passed in.
Exceptions
- DbnDecodeException
bufferis shorter than a record header, the header declares a length shorter than the header itself,bufferis shorter than that declared length, orhasTsOutis set on a record with no room for the timestamp.
Properties
Bytes
The record's bytes, exactly SizeInBytes long, ts_out included.
public ReadOnlySpan<byte> Bytes { get; }
Property Value
HasTsOut
true when the stream appends an 8-byte ts_out to every record.
public bool HasTsOut { get; }
Property Value
Header
The common record header, read in place.
public ref readonly RecordHeader Header { get; }
Property Value
IndexTs
The record's index timestamp: the one to sort by, and the one to key a symbol map with. Nanoseconds since the UNIX epoch.
public ulong IndexTs { get; }
Property Value
Remarks
Reach for this, not Header.TsEvent. Most schemas index on ts_recv,
and the two can fall on opposite sides of UTC midnight — so resolving a symbol by
ts_event silently returns the previous day's symbol, or nothing, with nothing
anywhere looking broken. See the remarks on IndexTs.
Port of upstream's RecordRef::raw_index_ts (record_ref.rs:336-346), which
dispatches on the record's rtype and falls back to ts_event when the dispatch does
not resolve. The switch below only names the rtypes whose struct carries
a ts_recv: every other family answers ts_event whether the dispatch resolves
or not, so listing them would add branches that cannot change the result.
Each arm still confirms the layout with Has<T>() before reading. A record
declaring a ts_recv-bearing rtype at a length no version of that struct has is not
one of them, and reading its bytes as that struct would produce a plausible number that is
not a timestamp. Those fall back to ts_event, which every record has.
The arms name concrete types rather than going through one generic helper, and that is
load-bearing, not verbosity. Every record struct is a readonly struct, so
reading IndexTs off the ref readonly that
Get<T>() returns is a field read in place. Routed through a
IndexTsOf<T>() helper instead, the compiler cannot see that
T.IndexTs is readonly from the constraint alone, so it emits ldobj !!T — a
defensive copy of the entire record, 520 bytes for an InstrumentDefMsg, to
read eight of them. Verified in the emitted IL, not assumed.
This is a raw timestamp and can be UndefTimestamp. Convert it with ToUtcDate(ulong) or TryToUtcDate(ulong, out LocalDate), which check the sentinel.
SizeInBytes
The record's total length on the wire in bytes, ts_out included. This is what the
header's Length word count multiplies out to, and what the
decoder advances the read buffer by.
public int SizeInBytes { get; }
Property Value
StructSize
The length of the record struct itself: SizeInBytes minus the 8 bytes of
ts_out when the stream appends one. This — never SizeInBytes —
is what gets compared against WireSize.
public int StructSize { get; }
Property Value
TsOut
The live gateway's send timestamp appended after the record, in nanoseconds since the UNIX epoch.
public ulong TsOut { get; }
Property Value
Exceptions
- InvalidOperationException
The stream does not append
ts_out— see HasTsOut. The eight bytes after a record on a stream withoutts_outbelong to the next record, so reading them as a timestamp would return a plausible-looking number that is not one.
Methods
Get<T>()
Reinterprets this record as a T in place — no copy, no allocation.
public ref readonly T Get<T>() where T : unmanaged, IRecord<T>
Returns
- T
A read-only reference into the decoder's buffer.
Type Parameters
TThe record struct to read this record as.
Remarks
The zero-copy accessor, and the one to use on the hot path. Pair it with Has<T>() when the record type is not already known, or use TryGet<T>(out T) when a copied value is more convenient than a reference.
Exceptions
- DbnDecodeException
This record is not a
T— see Has<T>().
Has<T>()
Reports whether this record is a T: its rtype is one
T decodes and its StructSize is exactly
WireSize.
public bool Has<T>() where T : unmanaged, IRecord<T>
Returns
Type Parameters
TThe record struct to test for.
Remarks
Both halves are load-bearing, and the size comparison is exact. An rtype
alone does not identify a record: InstrumentDef,
SymbolMapping, Error, System
and Statistics each decode to a different struct depending on the
record's length, because those layouts changed between DBN versions. A >=
comparison would let a 520-byte v3 InstrumentDefMsg answer true
for the 360-byte InstrumentDefMsgV1 and decode as the wrong version — silently,
since a reinterpret cannot fail. Upstream's own downcast (record_ref.rs:236-251)
uses >=; the size-dependent dispatch it relies on elsewhere
(rtype_dispatch_base!, macros.rs:14-69) picks the version by comparing the
record size against each struct's size, and exact equality is the same rule stated
without the ordering assumption.
This is a deliberate narrowing of upstream's has(), which checks only the
rtype and carries a documentation warning about exactly this hazard. Here
Has<T>() is true if and only if Get<T>()
succeeds, so there is no gap between the two for a caller to fall into.
TryGet<T>(out T)
Copies this record out as a T if it is one.
public bool TryGet<T>(out T record) where T : unmanaged, IRecord<T>
Parameters
recordTReceives a copy of the record, or default when this is not a
T.
Returns
Type Parameters
TThe record struct to read this record as.
Remarks
The counterpart of upstream's try_get(): a type mismatch is an ordinary, expected
outcome on a mixed-schema stream, so it is reported rather than thrown. Unlike
Get<T>() this copies the struct onto the caller's stack (at most 528 bytes,
never a heap allocation), which frees the value from the buffer's lifetime; prefer
Has<T>() plus Get<T>() where that copy is not wanted.