Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Timestamp Conversions

SBE timestamps are typically uint64 fields with a semanticType (UTCTimestamp = nanos, UTCTimestampMicros = micros, UTCTimestampMillis = millis). The chrono feature maps them to DateTime<Utc> / NaiveDateTime. Enable it on both Cargo entries:

[build-dependencies]
ergo-sbe = { version = "0.1", features = ["chrono"] }

[dependencies]
ergo-sbe = { version = "0.1", features = ["chrono"] }
chrono = "0.4"

Then register the converters by semanticType in build.rs:

use ergo_sbe::{ConversionSelector, GenerationConfig};

let config = GenerationConfig::new("msgs")
    .with_domain_type(
        ConversionSelector::semantic_type("UTCTimestamp"),
        "chrono::DateTime<chrono::Utc>",
    )
    .with_domain_type(
        ConversionSelector::semantic_type("UTCTimestampMicros"),
        "chrono::NaiveDateTime",
    );

Schema:

<field name="created_at"  id="1" type="uint64" semanticType="UTCTimestamp"/>
<field name="updated_at"  id="2" type="uint64" semanticType="UTCTimestampMicros"/>

Generated API — created_at() returns DateTime<Utc>, updated_at() returns NaiveDateTime:

// Decode
let created: chrono::DateTime<chrono::Utc> = dec.created_at()?;
let updated: chrono::NaiveDateTime = dec.updated_at()?;

// Encode
enc.created_at(chrono::Utc::now())?;
enc.updated_at(
    chrono::DateTime::from_timestamp_micros(1_720_000_000_000_000)
        .ok_or("micros out of range")?
        .naive_utc(),
)?;

timeUnit on a UTCTimestamp field

A uint64 UTCTimestamp field may say what it counts with timeUnit (second, millisecond, microsecond or nanosecond; nanoseconds when absent). The built-in DateTime<Utc> conversion honours it: the domain accessors read and write the field in its own unit, so one semantic_type("UTCTimestamp") mapping serves fields of every precision.

<field name="sentAt" id="1" type="uint64" semanticType="UTCTimestamp" timeUnit="millisecond"/>

Writing a DateTime more precise than the field — sub-millisecond digits into a millisecond field — is an error rather than a truncation; truncate it first (chrono::DurationRound::duration_trunc) when that is what you mean. Under DomainImpl::Manual your impl receives the field’s own unit.

One selector, many fields

ConversionSelector::semantic_type(..) matches every field in the schema carrying that semanticType — not just one. You register the conversion once, not once per field. A schema with three separate UTCTimestamp timestamps needs no more config than one with a single field:

<field name="createdAt"  id="1" type="uint64" semanticType="UTCTimestamp"/>
<field name="updatedAt"  id="2" type="uint64" semanticType="UTCTimestamp"/>
<field name="expiresAt"  id="3" type="uint64" semanticType="UTCTimestamp"/>
// Same one-time call as the two-field example above — no per-field repeats.
let config = GenerationConfig::new("msgs")
    .with_domain_type(
        ConversionSelector::semantic_type("UTCTimestamp"),
        "chrono::DateTime<chrono::Utc>",
    );

All three fields get their own concrete accessor, generated from that one call:

enc.created_at(chrono::Utc::now())?;
enc.updated_at(chrono::Utc::now())?;
enc.expires_at(chrono::Utc::now())?;

let created: chrono::DateTime<chrono::Utc> = dec.created_at()?;
let updated: chrono::DateTime<chrono::Utc> = dec.updated_at()?;
let expires: chrono::DateTime<chrono::Utc> = dec.expires_at()?;

This is why UTCTimestamp and UTCTimestampMicros need separate with_domain_type calls in the two-field example above — they’re different semanticType strings, so they’re different selectors — but adding a fourth UTCTimestamp field to the same schema needs no config change at all. ConversionSelector::field_path is the escape hatch when one specific field needs to differ from its semantic-type siblings. It works because selectors resolve by precedence, not registration order: an exact FieldPath beats a SemanticType, which beats a NamedType. So a blanket semantic_type("UTCTimestamp") mapping plus one field_path("Order.settledAt") override does what it reads like, regardless of which you register first.

Paths are "Message.field", extended with the group names for fields inside a group: "Order.legs.settledAt".

Mixed precisions, one app type — DomainImpl::Manual

The built-in converters give nanos → DateTime<Utc> and micros → NaiveDateTime — two different app types, because that’s what the built-in impls happen to produce. Real schemas often need the opposite: three fields at three different wire precisions, all normalized to the same DateTime<Utc> so downstream app code never branches on precision. None of the three is the bare uint64 + semanticType="UTCTimestamp" shape the built-in converter matches, so none gets an auto-generated impl — that’s exactly the case DomainImpl::Manual is for: concrete domain accessor signatures from ergo-sbe, conversion body from you.

Distinguish the three precisions with single-element composites (a distinct Rust type per precision — TimestampMillis(u64) is not TimestampNanos(u64) even though the wire shape is identical):

<composite name="TimestampMillis"><type name="ts" primitiveType="uint64"/></composite>
<composite name="TimestampMicros"><type name="ts" primitiveType="uint64"/></composite>
<composite name="TimestampNanos"><type name="ts" primitiveType="uint64"/></composite>

<field name="createdAt" id="1" type="TimestampNanos"/>
<field name="updatedAt" id="2" type="TimestampMicros"/>
<field name="deletedAt" id="3" type="TimestampMillis"/>

One with_manual_domain_type(selector, path) call per composite — same target type, rust_decimal-style — in build.rs:

use ergo_sbe::{ConversionSelector, GenerationConfig};

let config = GenerationConfig::new("msgs")
    .with_manual_domain_type(
        ConversionSelector::named_type("TimestampNanos"),
        "chrono::DateTime<chrono::Utc>",
    )
    .with_manual_domain_type(
        ConversionSelector::named_type("TimestampMicros"),
        "chrono::DateTime<chrono::Utc>",
    )
    .with_manual_domain_type(
        ConversionSelector::named_type("TimestampMillis"),
        "chrono::DateTime<chrono::Utc>",
    );

Each composite is a genuinely new named type, so there is no built-in template to offer — write the three impls yourself, one per precision (the traits live in the generated module as sbe_rt::TryFromSbe / sbe_rt::TryToSbe; import from there, not from ergo_sbe::codegen, which is crate-private):

// my_msgs is the module name passed to GenerationConfig::new("my_msgs")
use my_msgs::sbe_rt::{TryFromSbe, TryToSbe};
use my_msgs::{TimestampMillis, TimestampMicros, TimestampNanos};

impl TryFromSbe<TimestampNanos> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_from_sbe(wire: TimestampNanos) -> Result<Self, Self::Error> {
        // A single-member composite wraps its wire bytes: TimestampNanos([u8; 8]).
        let ns = u64::from_le_bytes(wire.0);
        chrono::DateTime::from_timestamp((ns / 1_000_000_000) as i64, (ns % 1_000_000_000) as u32)
            .ok_or("timestamp out of range")
    }
}
impl TryToSbe<TimestampNanos> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_to_sbe(&self) -> Result<TimestampNanos, Self::Error> {
        // The wire type is uint64: pre-epoch has no representation, so it is
        // an error. `as u64` on a negative i64 would wrap silently.
        let ns = self.timestamp_nanos_opt().ok_or("overflow")?;
        let ns = u64::try_from(ns).map_err(|_| "timestamp before 1970")?;
        Ok(TimestampNanos(ns.to_le_bytes()))
    }
}

impl TryFromSbe<TimestampMicros> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_from_sbe(wire: TimestampMicros) -> Result<Self, Self::Error> {
        let us = u64::from_le_bytes(wire.0);
        chrono::DateTime::from_timestamp((us / 1_000_000) as i64, ((us % 1_000_000) * 1_000) as u32)
            .ok_or("timestamp out of range")
    }
}
impl TryToSbe<TimestampMicros> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_to_sbe(&self) -> Result<TimestampMicros, Self::Error> {
        let us = u64::try_from(self.timestamp_micros()).map_err(|_| "timestamp before 1970")?;
        Ok(TimestampMicros(us.to_le_bytes()))
    }
}

impl TryFromSbe<TimestampMillis> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_from_sbe(wire: TimestampMillis) -> Result<Self, Self::Error> {
        let ms = u64::from_le_bytes(wire.0);
        chrono::DateTime::from_timestamp((ms / 1000) as i64, ((ms % 1000) * 1_000_000) as u32)
            .ok_or("timestamp out of range")
    }
}
impl TryToSbe<TimestampMillis> for chrono::DateTime<chrono::Utc> {
    type Error = &'static str;
    fn try_to_sbe(&self) -> Result<TimestampMillis, Self::Error> {
        let ms = u64::try_from(self.timestamp_millis()).map_err(|_| "timestamp before 1970")?;
        Ok(TimestampMillis(ms.to_le_bytes()))
    }
}

All three fields now return the exact same app type despite three different wire precisions — the caller never has to know or care which precision a given field was wire-encoded at:

let created: chrono::DateTime<chrono::Utc> = dec.created_at()?;
let updated: chrono::DateTime<chrono::Utc> = dec.updated_at()?;
let deleted: chrono::DateTime<chrono::Utc> = dec.deleted_at()?;

enc.created_at(chrono::Utc::now())?;
enc.updated_at(chrono::Utc::now())?;
enc.deleted_at(chrono::Utc::now())?;

If you forget one of the three impls, the compile error names it directly — `chrono::DateTime<Utc>` has no `TryFromSbe<TimestampMillis>` impl — instead of the default trait-bound message.