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
36use std:: collections:: HashMap ;
47
5- use sonic_rs:: { JsonContainerTrait , JsonValueTrait } ;
6-
7- use super :: msgpack_decode:: { self , MsgpackValue } ;
88use crate :: bridge:: envelope:: { ErrorCode , Payload , Response , Status } ;
99use crate :: data:: executor:: core_loop:: CoreLoop ;
1010use 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