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.