Skip to main content

laser_sdk/stream/
record.rs

1use crate::error::LaserError;
2use crate::stream::{
3    CONTENT_TYPE, ContentType, IDX_PREFIX, INLINE_PAYLOAD, LOGICAL_SCHEMA_FINGERPRINT,
4    MAX_INDEX_ENTRIES_PER_RECORD, PROJECTION_REF, SCHEMA_ID,
5};
6use iggy::prelude::{HeaderKey, HeaderValue};
7use laser_wire::headers::{HEADER_FRAMING_BYTES, HEADER_SOFT_CAP, HEADER_VALUE_MAX};
8use std::collections::BTreeMap;
9use std::str::FromStr;
10
11/// A record to publish: payload plus indexed fields and metadata (built via the publish builder).
12#[derive(Clone, Debug, Default, bon::Builder)]
13pub struct Record {
14    // Explicit indexed fields override schema extraction. A record produces no row only when both sources provide no indexed fields.
15    #[builder(field)]
16    pub index: Vec<(String, String)>,
17    // Ride-along headers stamped verbatim so the projector can store them in a
18    // metadata column. Use for trace ids, user tags, content classification,
19    // anything callers want next to the row without paying the index cost.
20    #[builder(field)]
21    pub metadata: Vec<(String, String)>,
22    // Whether the projector inlines the payload bytes alongside the row in the
23    // query index. Default is `false`. Iggy still keeps the original message
24    // in the log, but the materialized row only carries indexed scalars +
25    // metadata. Opt in for self-describing bodies callers want back through
26    // queries. A builder field so the setter is the unary `.inline_payload()`
27    // opt-in, matching the accessor grammar (never `.thing(true)`).
28    #[builder(field)]
29    pub inline_payload: bool,
30    /// Wire content-type. `None` means no `agdx.ct` header is stamped
31    /// (consumer treats the payload as opaque bytes). `Some(ContentType::*)`
32    /// stamps the tag so downstream readers know how to decode. The codec
33    /// helpers (`.json`, `.msgpack`, `.encode_with::<C>`, `.raw_bytes`) set
34    /// it automatically. An explicit `.content_type(ContentType::Raw)` also
35    /// stamps it (distinct from "unset").
36    pub content_type: Option<ContentType>,
37    // Projection selector for the materialization LaserData Cloud performs. Opaque string like
38    // `"reading.v1"`. When set, the projector looks it up in the binding's
39    // `allowed_projections` and applies the matching `Projection` extraction
40    // rules. When unset, the binding's default projection (or the explicit
41    // `agdx.idx.*` header path) applies.
42    #[builder(into)]
43    pub projection_ref: Option<String>,
44    // Writer-schema id for a schema-first codec (Avro, Protobuf), stamped as
45    // `agdx.sid`. The managed backend resolves it against the schema registry. It does
46    // not select materialization routing, which uses `projection_ref`.
47    pub schema_id: Option<u32>,
48    /// Logical schema fingerprint required for an Arrow IPC record.
49    pub logical_schema_fingerprint: Option<laser_wire::schema::SchemaFingerprint>,
50}
51
52impl<S: record_builder::State> RecordBuilder<S> {
53    /// Add an indexed scalar under `agdx.idx.<key>`. Validation (non-empty key,
54    /// no reserved `agdx.idx.` prefix, count cap) runs at `.send().await?` time
55    /// so the builder stays a simple fluent push. Errors surface as
56    /// `LaserError::Invalid` with `record #<idx>: ...` context from the
57    /// batch loop.
58    pub fn index(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
59        self.index.push((key.into(), value.into()));
60        self
61    }
62
63    /// Add a ride-along metadata header (not indexed).
64    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
65        self.metadata.push((key.into(), value.into()));
66        self
67    }
68
69    /// Ask the projector to inline the payload bytes alongside the row in the
70    /// query index (off by default). Unary opt-in.
71    pub fn inline_payload(mut self) -> Self {
72        self.inline_payload = true;
73        self
74    }
75}
76
77impl TryFrom<&Record> for BTreeMap<HeaderKey, HeaderValue> {
78    type Error = LaserError;
79
80    fn try_from(record: &Record) -> Result<Self, Self::Error> {
81        let mut map: BTreeMap<HeaderKey, HeaderValue> = BTreeMap::new();
82        // Reserved keys carry TYPED values (u8 code / u32 id / bool flag), not
83        // strings: 1-4 value bytes instead of up to 8 name characters, on
84        // every message.
85        if let Some(content_type) = record.content_type {
86            map.insert(
87                HeaderKey::from_str(CONTENT_TYPE)?,
88                HeaderValue::from(content_type.code()),
89            );
90        }
91        if let Some(ref projection_ref) = record.projection_ref {
92            put_header(&mut map, PROJECTION_REF, projection_ref)?;
93        }
94        if let Some(schema_id) = record.schema_id {
95            map.insert(
96                HeaderKey::from_str(SCHEMA_ID)?,
97                HeaderValue::from(schema_id),
98            );
99        }
100        if record.content_type == Some(ContentType::Arrow) {
101            let fingerprint = record.logical_schema_fingerprint.as_ref().ok_or_else(|| {
102                LaserError::Invalid(
103                    "Arrow IPC records require a logical schema fingerprint".to_owned(),
104                )
105            })?;
106            laser_wire::validate::Validate::validate(fingerprint)?;
107            map.insert(
108                HeaderKey::from_str(LOGICAL_SCHEMA_FINGERPRINT)?,
109                HeaderValue::try_from(fingerprint.as_bytes())?,
110            );
111        } else if record.logical_schema_fingerprint.is_some() {
112            return Err(LaserError::Invalid(
113                "a logical schema fingerprint is valid only for Arrow IPC records".to_owned(),
114            ));
115        }
116        if record.inline_payload {
117            map.insert(
118                HeaderKey::from_str(INLINE_PAYLOAD)?,
119                HeaderValue::from(true),
120            );
121        }
122        if record.index.len() > MAX_INDEX_ENTRIES_PER_RECORD {
123            return Err(LaserError::Invalid(format!(
124                "record has {} indexed scalars, exceeds cap of {MAX_INDEX_ENTRIES_PER_RECORD}",
125                record.index.len()
126            )));
127        }
128        for (key, value) in &record.index {
129            if key.is_empty() {
130                return Err(LaserError::Invalid(
131                    "index key must not be empty".to_owned(),
132                ));
133            }
134            if key.starts_with(IDX_PREFIX) {
135                return Err(LaserError::Invalid(format!(
136                    "index key `{key}` must not start with the reserved `agdx.idx.` prefix"
137                )));
138            }
139            put_header(&mut map, &format!("{IDX_PREFIX}{key}"), value)?;
140        }
141        for (key, value) in &record.metadata {
142            if key.starts_with(IDX_PREFIX) {
143                return Err(LaserError::Invalid(format!(
144                    "metadata header `{key}` collides with the `agdx.idx.` namespace - use .index() instead"
145                )));
146            }
147            if matches!(
148                key.as_str(),
149                CONTENT_TYPE
150                    | SCHEMA_ID
151                    | LOGICAL_SCHEMA_FINGERPRINT
152                    | PROJECTION_REF
153                    | INLINE_PAYLOAD
154            ) {
155                return Err(LaserError::Invalid(format!(
156                    "metadata header `{key}` is reserved - set it via the dedicated builder method"
157                )));
158            }
159            put_header(&mut map, key, value)?;
160        }
161        let size: usize = map
162            .iter()
163            .map(|(k, v)| k.as_bytes().len() + v.as_bytes().len() + HEADER_FRAMING_BYTES)
164            .sum();
165        if size > HEADER_SOFT_CAP {
166            return Err(LaserError::Invalid(format!(
167                "record headers {size}B exceed soft cap {HEADER_SOFT_CAP}B"
168            )));
169        }
170        Ok(map)
171    }
172}
173
174fn put_header(
175    map: &mut BTreeMap<HeaderKey, HeaderValue>,
176    key: &str,
177    value: &str,
178) -> Result<(), LaserError> {
179    if value.is_empty() {
180        return Err(LaserError::Invalid(format!(
181            "header `{key}` value must not be empty"
182        )));
183    }
184    if value.len() > HEADER_VALUE_MAX {
185        return Err(LaserError::Invalid(format!(
186            "header `{key}` value is {}B, exceeds max {HEADER_VALUE_MAX}B",
187            value.len()
188        )));
189    }
190    map.insert(HeaderKey::from_str(key)?, HeaderValue::from_str(value)?);
191    Ok(())
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn given_a_record_when_lowered_then_should_stamp_compact_typed_headers() {
200        let record = Record::builder()
201            .content_type(ContentType::Json)
202            .schema_id(7)
203            .index("job_id", "123")
204            .index("host_id", "abc")
205            .build();
206        let headers: BTreeMap<HeaderKey, HeaderValue> =
207            (&record).try_into().expect("the record lowers to headers");
208
209        let get = |key: &str| {
210            headers
211                .get(&HeaderKey::from_str(key).expect("the key is valid"))
212                .cloned()
213        };
214        // Reserved keys ride compact + typed: 1-byte content-type code,
215        // 4-byte u32 schema id.
216        let content_type = get(CONTENT_TYPE).expect("agdx.ct stamped");
217        assert_eq!(
218            content_type.as_uint8().expect("u8 typed value"),
219            ContentType::Json.code(),
220        );
221        let schema_id = get(SCHEMA_ID).expect("agdx.sid stamped");
222        assert_eq!(schema_id.as_uint32().expect("u32 typed value"), 7);
223        let as_str =
224            |key: &str| get(key).map(|value| value.as_str().expect("utf-8 value").to_owned());
225        assert_eq!(as_str("agdx.idx.job_id").as_deref(), Some("123"));
226        assert_eq!(as_str("agdx.idx.host_id").as_deref(), Some("abc"));
227        assert_eq!(get(INLINE_PAYLOAD), None, "off by default");
228    }
229
230    #[test]
231    fn given_a_record_without_content_type_when_lowered_then_should_omit_the_header() {
232        let record = Record::builder().index("host_id", "123").build();
233        let headers: BTreeMap<HeaderKey, HeaderValue> =
234            (&record).try_into().expect("the record lowers to headers");
235        let key = HeaderKey::from_str(CONTENT_TYPE).expect("the key is valid");
236        assert!(
237            !headers.contains_key(&key),
238            "agdx.ct must NOT be stamped when Record.content_type is None",
239        );
240    }
241
242    #[test]
243    fn given_inline_payload_and_metadata_when_lowered_then_should_emit_both() {
244        let record = Record::builder()
245            .index("host_id", "123")
246            .metadata("trace_id", "abc")
247            .metadata("agdx.actor", "metrics")
248            .inline_payload()
249            .build();
250        let headers: BTreeMap<HeaderKey, HeaderValue> =
251            (&record).try_into().expect("the record lowers to headers");
252
253        let inline = headers
254            .get(&HeaderKey::from_str(INLINE_PAYLOAD).expect("the key is valid"))
255            .expect("agdx.inline stamped");
256        assert!(inline.as_bool().expect("bool typed value"));
257        let get = |key: &str| {
258            headers
259                .get(&HeaderKey::from_str(key).expect("the key is valid"))
260                .map(|value| value.as_str().expect("the value is utf-8").to_owned())
261        };
262        assert_eq!(get("trace_id").as_deref(), Some("abc"));
263        assert_eq!(get("agdx.actor").as_deref(), Some("metrics"));
264    }
265
266    #[test]
267    fn given_metadata_in_idx_namespace_when_lowered_then_should_error() {
268        let record = Record::builder().metadata("agdx.idx.smuggled", "x").build();
269        let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
270        assert!(matches!(result, Err(LaserError::Invalid(_))));
271    }
272
273    #[test]
274    fn given_metadata_for_a_reserved_key_when_lowered_then_should_error() {
275        for reserved in [CONTENT_TYPE, SCHEMA_ID, INLINE_PAYLOAD] {
276            let record = Record::builder().metadata(reserved, "smuggled").build();
277            let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
278            assert!(
279                matches!(result, Err(LaserError::Invalid(_))),
280                "reserved key `{reserved}` must not be smuggleable through metadata"
281            );
282        }
283    }
284
285    #[test]
286    fn given_a_record_with_an_empty_index_value_when_lowered_then_should_error() {
287        let record = Record::builder().index("host_id", "").build();
288        let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
289        assert!(matches!(result, Err(LaserError::Invalid(_))));
290    }
291
292    #[test]
293    fn given_a_record_with_an_oversized_index_value_when_lowered_then_should_error() {
294        let record = Record::builder()
295            .index("blob", "x".repeat(HEADER_VALUE_MAX + 1))
296            .build();
297        let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
298        assert!(matches!(result, Err(LaserError::Invalid(_))));
299    }
300
301    #[test]
302    fn given_an_arrow_record_when_lowered_then_should_stamp_the_raw_fingerprint() {
303        let fingerprint = laser_wire::schema::SchemaFingerprint::new([0xab; 32]);
304        let record = Record::builder()
305            .content_type(ContentType::Arrow)
306            .logical_schema_fingerprint(fingerprint.clone())
307            .build();
308        let headers: BTreeMap<HeaderKey, HeaderValue> = (&record)
309            .try_into()
310            .expect("the Arrow record lowers to headers");
311        let value = headers
312            .get(&HeaderKey::from_str(LOGICAL_SCHEMA_FINGERPRINT).expect("header key is valid"))
313            .expect("schema fingerprint is stamped");
314        assert_eq!(value.as_bytes(), fingerprint.as_bytes());
315    }
316
317    #[test]
318    fn given_arrow_content_without_a_fingerprint_when_lowered_then_should_error() {
319        let record = Record::builder().content_type(ContentType::Arrow).build();
320        let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
321        assert!(matches!(result, Err(LaserError::Invalid(_))));
322    }
323
324    #[test]
325    fn given_non_arrow_content_with_a_fingerprint_when_lowered_then_should_error() {
326        let record = Record::builder()
327            .content_type(ContentType::Json)
328            .logical_schema_fingerprint(laser_wire::schema::SchemaFingerprint::new([1; 32]))
329            .build();
330        let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
331        assert!(matches!(result, Err(LaserError::Invalid(_))));
332    }
333
334    #[test]
335    fn given_a_record_with_an_empty_index_key_when_lowered_then_should_error() {
336        let record = Record::builder().index("", "value").build();
337        let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
338        assert!(matches!(result, Err(LaserError::Invalid(_))));
339    }
340
341    #[test]
342    fn given_a_record_with_a_reserved_idx_prefix_key_when_lowered_then_should_error() {
343        let record = Record::builder().index("agdx.idx.shadow", "value").build();
344        let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
345        let message = match result {
346            Err(LaserError::Invalid(message)) => message,
347            other => panic!("expected an invalid error, got {other:?}"),
348        };
349        assert!(
350            message.contains("agdx.idx."),
351            "error mentions the reserved prefix: {message}"
352        );
353    }
354
355    #[test]
356    fn given_a_record_with_more_than_the_index_cap_when_lowered_then_should_error() {
357        let mut builder = Record::builder();
358        for i in 0..(MAX_INDEX_ENTRIES_PER_RECORD + 1) {
359            builder = builder.index(format!("k{i}"), format!("v{i}"));
360        }
361        let record = builder.build();
362        let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
363        let message = match result {
364            Err(LaserError::Invalid(message)) => message,
365            other => panic!("expected an invalid error, got {other:?}"),
366        };
367        assert!(
368            message.contains(&MAX_INDEX_ENTRIES_PER_RECORD.to_string()),
369            "error mentions the cap: {message}"
370        );
371    }
372}