Table of Contents

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

buffer ReadOnlySpan<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.

hasTsOut bool

Whether the stream appends an 8-byte ts_out send 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

buffer is shorter than a record header, the header declares a length shorter than the header itself, buffer is shorter than that declared length, or hasTsOut is 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

ReadOnlySpan<byte>

HasTsOut

true when the stream appends an 8-byte ts_out to every record.

public bool HasTsOut { get; }

Property Value

bool

Header

The common record header, read in place.

public ref readonly RecordHeader Header { get; }

Property Value

RecordHeader

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

ulong

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

int

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

int

TsOut

The live gateway's send timestamp appended after the record, in nanoseconds since the UNIX epoch.

public ulong TsOut { get; }

Property Value

ulong

Exceptions

InvalidOperationException

The stream does not append ts_out — see HasTsOut. The eight bytes after a record on a stream without ts_out belong 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

T

The 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

bool

true if Get<T>() would succeed.

Type Parameters

T

The 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

record T

Receives a copy of the record, or default when this is not a T.

Returns

bool

true if this record is a T.

Type Parameters

T

The 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.