Skip to content

Commit 5cbabfb

Browse files
committed
feat(nodedb): add bitemporal stamping to timeseries ILP ingest
Extend the ILP ingest path to stamp rows with bitemporal columns when the target collection is configured as bitemporal. The executor handler now resolves the bitemporal flag before memtable creation and passes BitempStamps through ingest_batch_with_lvc. On the engine side, ingest_batch_with_lvc gains an optional BitempStamps parameter. When present, _ts_system is set to the server ingest time, while _ts_valid_from and _ts_valid_until are sourced from the line's field set, defaulting to the open interval [i64::MIN, i64::MAX). Schema inference and evolution logic is delegated to the ilp_schema sibling module (via re-exports), and the msgpack ingest handler is moved to the ingest_formats sibling module, keeping both files within the 500-line limit. Adds a unit test covering late-arriving IoT backfill to verify the three reserved column slots carry the expected stamps.
1 parent 7b989e8 commit 5cbabfb

2 files changed

Lines changed: 131 additions & 362 deletions

File tree

Lines changed: 16 additions & 266 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
//! Timeseries ILP ingest handler.
2+
//!
3+
//! msgpack / JSON row ingests that normalize into ILP text live in the
4+
//! sibling `ingest_formats` module.
25
36
use std::collections::HashMap;
47

5-
use sonic_rs::{JsonContainerTrait, JsonValueTrait};
6-
7-
use super::msgpack_decode::{self, MsgpackValue};
88
use crate::bridge::envelope::{ErrorCode, Payload, Response, Status};
99
use crate::data::executor::core_loop::CoreLoop;
1010
use crate::data::executor::response_codec;
@@ -91,7 +91,7 @@ impl CoreLoop {
9191
}
9292
}
9393

94-
fn execute_ilp_ingest(
94+
pub(super) fn execute_ilp_ingest(
9595
&mut self,
9696
task: &ExecutionTask,
9797
tid: crate::types::TenantId,
@@ -127,10 +127,14 @@ impl CoreLoop {
127127
);
128128
}
129129

130+
let bitemporal = self.is_bitemporal(tid.as_u32(), collection);
130131
// Ensure memtable exists (auto-create on first write).
131132
let is_new_memtable = !self.columnar_memtables.contains_key(&key);
132133
if is_new_memtable {
133-
let schema = ilp_ingest::infer_schema(&lines);
134+
let mut schema = ilp_ingest::infer_schema(&lines);
135+
if bitemporal {
136+
ilp_ingest::ensure_bitemporal_columns(&mut schema);
137+
}
134138
let config = ColumnarMemtableConfig {
135139
max_memory_bytes: 64 * 1024 * 1024,
136140
hard_memory_limit: 80 * 1024 * 1024,
@@ -184,10 +188,15 @@ impl CoreLoop {
184188
let _ = gov.try_reserve(nodedb_mem::EngineId::Timeseries, batch_estimate);
185189
}
186190

191+
let stamps = if bitemporal {
192+
Some(ilp_ingest::BitempStamps { system_ms: now_ms })
193+
} else {
194+
None
195+
};
187196
let lvc = self.ts_last_value_caches.get_mut(&key);
188197
let mut series_keys = HashMap::new();
189198
let (mut accepted, rejected) =
190-
ilp_ingest::ingest_batch_with_lvc(mt, &lines, &mut series_keys, now_ms, lvc);
199+
ilp_ingest::ingest_batch_with_lvc(mt, &lines, &mut series_keys, now_ms, lvc, stamps);
191200

192201
// If rows were rejected (memtable hit hard limit), flush and re-ingest.
193202
if rejected > 0 {
@@ -208,6 +217,7 @@ impl CoreLoop {
208217
&mut retry_keys,
209218
now_ms,
210219
retry_lvc,
220+
stamps,
211221
);
212222
accepted += retry_accepted;
213223
}
@@ -289,264 +299,4 @@ impl CoreLoop {
289299
error_code: None,
290300
}
291301
}
292-
293-
/// Ingest JSON row objects from SQL INSERT path.
294-
///
295-
/// Payload is a msgpack array of maps (same schema as JSON ingest but in msgpack).
296-
/// Converts each row to an ILP line and delegates to the ILP ingest path.
297-
fn execute_msgpack_ingest(
298-
&mut self,
299-
task: &ExecutionTask,
300-
tid: crate::types::TenantId,
301-
collection: &str,
302-
payload: &[u8],
303-
wal_lsn: Option<u64>,
304-
now_ms: i64,
305-
) -> Response {
306-
let measurement = collection
307-
.split_once(':')
308-
.map(|(_, name)| name)
309-
.unwrap_or(collection);
310-
311-
if !measurement
312-
.chars()
313-
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
314-
{
315-
return self.response_error(
316-
task,
317-
ErrorCode::Internal {
318-
detail: format!(
319-
"invalid measurement name '{measurement}': only [a-zA-Z0-9_-] allowed"
320-
),
321-
},
322-
);
323-
}
324-
325-
let rows = match msgpack_decode::decode_msgpack_rows(payload) {
326-
Ok(r) => r,
327-
Err(e) => {
328-
return self.response_error(
329-
task,
330-
ErrorCode::Internal {
331-
detail: format!("msgpack decode error: {e}"),
332-
},
333-
);
334-
}
335-
};
336-
337-
if rows.is_empty() {
338-
return self.response_error(
339-
task,
340-
ErrorCode::Internal {
341-
detail: "empty msgpack rows array".into(),
342-
},
343-
);
344-
}
345-
346-
let mut ilp_buf = String::new();
347-
for row in &rows {
348-
let mut fields = Vec::new();
349-
let mut timestamp_ns: Option<i64> = None;
350-
351-
for (key, val) in row {
352-
let lower = key.to_lowercase();
353-
if lower == "ts" || lower == "timestamp" || lower == "time" {
354-
match val {
355-
MsgpackValue::Str(s) => {
356-
timestamp_ns = parse_ts_string_to_nanos(s);
357-
}
358-
MsgpackValue::Int(n) => {
359-
timestamp_ns = Some(*n * 1_000_000);
360-
}
361-
MsgpackValue::Float(f) => {
362-
timestamp_ns = Some(*f as i64 * 1_000_000);
363-
}
364-
_ => {}
365-
}
366-
continue;
367-
}
368-
369-
match val {
370-
MsgpackValue::Float(f) => fields.push(format!("{key}={f}")),
371-
MsgpackValue::Int(n) => fields.push(format!("{key}={n}i")),
372-
MsgpackValue::Str(s) => {
373-
fields.push(format!("{key}=\"{}\"", s.replace('\"', "\\\"")));
374-
}
375-
MsgpackValue::Bool(b) => fields.push(format!("{key}={b}")),
376-
_ => {}
377-
}
378-
}
379-
380-
if fields.is_empty() {
381-
continue;
382-
}
383-
384-
ilp_buf.push_str(measurement);
385-
ilp_buf.push(' ');
386-
ilp_buf.push_str(&fields.join(","));
387-
if let Some(ts) = timestamp_ns {
388-
ilp_buf.push(' ');
389-
ilp_buf.push_str(&ts.to_string());
390-
}
391-
ilp_buf.push('\n');
392-
}
393-
394-
if ilp_buf.is_empty() {
395-
return self.response_error(
396-
task,
397-
ErrorCode::Internal {
398-
detail: "no valid rows in msgpack payload".into(),
399-
},
400-
);
401-
}
402-
403-
self.execute_ilp_ingest(task, tid, collection, ilp_buf.as_bytes(), wal_lsn, now_ms)
404-
}
405-
406-
/// Payload is a JSON array like: `[{"id":"e1","ts":"2024-01-01T00:00:00Z","value":42.0}]`.
407-
/// Converts each row to an ILP line and delegates to the ILP ingest path.
408-
fn execute_json_ingest(
409-
&mut self,
410-
task: &ExecutionTask,
411-
tid: crate::types::TenantId,
412-
collection: &str,
413-
payload: &[u8],
414-
wal_lsn: Option<u64>,
415-
now_ms: i64,
416-
) -> Response {
417-
let rows: sonic_rs::Array = match sonic_rs::from_slice(payload) {
418-
Ok(r) => r,
419-
Err(e) => {
420-
return self.response_error(
421-
task,
422-
ErrorCode::Internal {
423-
detail: format!("JSON parse error: {e}"),
424-
},
425-
);
426-
}
427-
};
428-
429-
if rows.is_empty() {
430-
return self.response_error(
431-
task,
432-
ErrorCode::Internal {
433-
detail: "empty JSON rows array".into(),
434-
},
435-
);
436-
}
437-
438-
// Convert JSON rows to ILP text.
439-
// The collection name serves as the ILP measurement.
440-
// Strip tenant scope prefix if present (e.g., "1:events" → "events").
441-
let measurement = collection
442-
.split_once(':')
443-
.map(|(_, name)| name)
444-
.unwrap_or(collection);
445-
446-
// Validate measurement name: only [a-zA-Z0-9_-] allowed.
447-
// ILP special characters (space, comma, =, newline) in measurement names corrupt the line.
448-
if !measurement
449-
.chars()
450-
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
451-
{
452-
return self.response_error(
453-
task,
454-
ErrorCode::Internal {
455-
detail: format!(
456-
"invalid measurement name '{measurement}': only [a-zA-Z0-9_-] allowed"
457-
),
458-
},
459-
);
460-
}
461-
462-
let mut ilp_buf = String::new();
463-
for row_val in rows.iter() {
464-
let obj = match row_val.as_object() {
465-
Some(o) => o,
466-
None => continue,
467-
};
468-
469-
let mut fields = Vec::new();
470-
let mut timestamp_ns: Option<i64> = None;
471-
472-
for (key, val) in obj.iter() {
473-
let lower = key.to_lowercase();
474-
if lower == "ts" || lower == "timestamp" || lower == "time" {
475-
if let Some(s) = val.as_str() {
476-
timestamp_ns = parse_ts_string_to_nanos(s);
477-
} else if let Some(n) = val.as_i64() {
478-
// Treat integer timestamps as milliseconds → convert to ns.
479-
timestamp_ns = Some(n * 1_000_000);
480-
} else if let Some(f) = val.as_f64() {
481-
timestamp_ns = Some(f as i64 * 1_000_000);
482-
}
483-
continue;
484-
}
485-
486-
if let Some(f) = val.as_f64() {
487-
fields.push(format!("{key}={f}"));
488-
} else if let Some(n) = val.as_i64() {
489-
fields.push(format!("{key}={n}i"));
490-
} else if let Some(s) = val.as_str() {
491-
fields.push(format!("{key}=\"{}\"", s.replace('\"', "\\\"")));
492-
} else if let Some(b) = val.as_bool() {
493-
fields.push(format!("{key}={b}"));
494-
}
495-
}
496-
497-
if fields.is_empty() {
498-
continue;
499-
}
500-
501-
ilp_buf.push_str(measurement);
502-
ilp_buf.push(' ');
503-
ilp_buf.push_str(&fields.join(","));
504-
if let Some(ts) = timestamp_ns {
505-
ilp_buf.push(' ');
506-
ilp_buf.push_str(&ts.to_string());
507-
}
508-
ilp_buf.push('\n');
509-
}
510-
511-
if ilp_buf.is_empty() {
512-
return self.response_error(
513-
task,
514-
ErrorCode::Internal {
515-
detail: "no valid rows in JSON payload".into(),
516-
},
517-
);
518-
}
519-
520-
// Delegate to the ILP ingest path.
521-
self.execute_ilp_ingest(task, tid, collection, ilp_buf.as_bytes(), wal_lsn, now_ms)
522-
}
523-
}
524-
525-
/// Parse a datetime string to nanoseconds since Unix epoch.
526-
///
527-
/// Accepts RFC3339 / ISO8601 with timezone (e.g., "2024-01-01T00:00:00Z"),
528-
/// and common datetime formats without timezone (treated as UTC).
529-
/// Returns nanoseconds since Unix epoch, or `None` if the string cannot be parsed.
530-
fn parse_ts_string_to_nanos(s: &str) -> Option<i64> {
531-
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
532-
533-
// Try RFC3339 first (includes timezone info).
534-
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
535-
return dt.timestamp_nanos_opt();
536-
}
537-
538-
// Try common space-separated format "YYYY-MM-DD HH:MM:SS[.frac]".
539-
let formats = [
540-
"%Y-%m-%d %H:%M:%S%.f",
541-
"%Y-%m-%d %H:%M:%S",
542-
"%Y-%m-%dT%H:%M:%S%.f",
543-
"%Y-%m-%dT%H:%M:%S",
544-
];
545-
for fmt in &formats {
546-
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, fmt) {
547-
return Utc.from_utc_datetime(&ndt).timestamp_nanos_opt();
548-
}
549-
}
550-
551-
None
552302
}

0 commit comments

Comments
 (0)