|
2 | 2 |
|
3 | 3 | use std::collections::HashMap; |
4 | 4 |
|
| 5 | +use sonic_rs::{JsonContainerTrait, JsonValueTrait}; |
| 6 | + |
5 | 7 | use crate::bridge::envelope::{ErrorCode, Payload, Response, Status}; |
6 | 8 | use crate::data::executor::core_loop::CoreLoop; |
7 | 9 | use crate::data::executor::response_codec; |
@@ -73,6 +75,7 @@ impl CoreLoop { |
73 | 75 |
|
74 | 76 | match format { |
75 | 77 | "ilp" => self.execute_ilp_ingest(task, collection, payload, wal_lsn, now_ms), |
| 78 | + "json" => self.execute_json_ingest(task, collection, payload, wal_lsn, now_ms), |
76 | 79 | _ => self.response_error( |
77 | 80 | task, |
78 | 81 | ErrorCode::Internal { |
@@ -281,4 +284,152 @@ impl CoreLoop { |
281 | 284 | error_code: None, |
282 | 285 | } |
283 | 286 | } |
| 287 | + |
| 288 | + /// Ingest JSON row objects from SQL INSERT path. |
| 289 | + /// |
| 290 | + /// Payload is a JSON array like: `[{"id":"e1","ts":"2024-01-01T00:00:00Z","value":42.0}]`. |
| 291 | + /// Converts each row to an ILP line and delegates to the ILP ingest path. |
| 292 | + fn execute_json_ingest( |
| 293 | + &mut self, |
| 294 | + task: &ExecutionTask, |
| 295 | + collection: &str, |
| 296 | + payload: &[u8], |
| 297 | + wal_lsn: Option<u64>, |
| 298 | + now_ms: i64, |
| 299 | + ) -> Response { |
| 300 | + let rows: sonic_rs::Array = match sonic_rs::from_slice(payload) { |
| 301 | + Ok(r) => r, |
| 302 | + Err(e) => { |
| 303 | + return self.response_error( |
| 304 | + task, |
| 305 | + ErrorCode::Internal { |
| 306 | + detail: format!("JSON parse error: {e}"), |
| 307 | + }, |
| 308 | + ); |
| 309 | + } |
| 310 | + }; |
| 311 | + |
| 312 | + if rows.is_empty() { |
| 313 | + return self.response_error( |
| 314 | + task, |
| 315 | + ErrorCode::Internal { |
| 316 | + detail: "empty JSON rows array".into(), |
| 317 | + }, |
| 318 | + ); |
| 319 | + } |
| 320 | + |
| 321 | + // Convert JSON rows to ILP text. |
| 322 | + // The collection name serves as the ILP measurement. |
| 323 | + // Strip tenant scope prefix if present (e.g., "1:events" → "events"). |
| 324 | + let measurement = collection |
| 325 | + .split_once(':') |
| 326 | + .map(|(_, name)| name) |
| 327 | + .unwrap_or(collection); |
| 328 | + |
| 329 | + // Validate measurement name: only [a-zA-Z0-9_-] allowed. |
| 330 | + // ILP special characters (space, comma, =, newline) in measurement names corrupt the line. |
| 331 | + if !measurement |
| 332 | + .chars() |
| 333 | + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') |
| 334 | + { |
| 335 | + return self.response_error( |
| 336 | + task, |
| 337 | + ErrorCode::Internal { |
| 338 | + detail: format!( |
| 339 | + "invalid measurement name '{measurement}': only [a-zA-Z0-9_-] allowed" |
| 340 | + ), |
| 341 | + }, |
| 342 | + ); |
| 343 | + } |
| 344 | + |
| 345 | + let mut ilp_buf = String::new(); |
| 346 | + for row_val in rows.iter() { |
| 347 | + let obj = match row_val.as_object() { |
| 348 | + Some(o) => o, |
| 349 | + None => continue, |
| 350 | + }; |
| 351 | + |
| 352 | + let mut fields = Vec::new(); |
| 353 | + let mut timestamp_ns: Option<i64> = None; |
| 354 | + |
| 355 | + for (key, val) in obj.iter() { |
| 356 | + let lower = key.to_lowercase(); |
| 357 | + if lower == "ts" || lower == "timestamp" || lower == "time" { |
| 358 | + if let Some(s) = val.as_str() { |
| 359 | + timestamp_ns = parse_ts_string_to_nanos(s); |
| 360 | + } else if let Some(n) = val.as_i64() { |
| 361 | + // Treat integer timestamps as milliseconds → convert to ns. |
| 362 | + timestamp_ns = Some(n * 1_000_000); |
| 363 | + } else if let Some(f) = val.as_f64() { |
| 364 | + timestamp_ns = Some(f as i64 * 1_000_000); |
| 365 | + } |
| 366 | + continue; |
| 367 | + } |
| 368 | + |
| 369 | + if let Some(f) = val.as_f64() { |
| 370 | + fields.push(format!("{key}={f}")); |
| 371 | + } else if let Some(n) = val.as_i64() { |
| 372 | + fields.push(format!("{key}={n}i")); |
| 373 | + } else if let Some(s) = val.as_str() { |
| 374 | + fields.push(format!("{key}=\"{}\"", s.replace('\"', "\\\""))); |
| 375 | + } else if let Some(b) = val.as_bool() { |
| 376 | + fields.push(format!("{key}={b}")); |
| 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 JSON payload".into(), |
| 399 | + }, |
| 400 | + ); |
| 401 | + } |
| 402 | + |
| 403 | + // Delegate to the ILP ingest path. |
| 404 | + self.execute_ilp_ingest(task, collection, ilp_buf.as_bytes(), wal_lsn, now_ms) |
| 405 | + } |
| 406 | +} |
| 407 | + |
| 408 | +/// Parse a datetime string to nanoseconds since Unix epoch. |
| 409 | +/// |
| 410 | +/// Accepts RFC3339 / ISO8601 with timezone (e.g., "2024-01-01T00:00:00Z"), |
| 411 | +/// and common datetime formats without timezone (treated as UTC). |
| 412 | +/// Returns nanoseconds since Unix epoch, or `None` if the string cannot be parsed. |
| 413 | +fn parse_ts_string_to_nanos(s: &str) -> Option<i64> { |
| 414 | + use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; |
| 415 | + |
| 416 | + // Try RFC3339 first (includes timezone info). |
| 417 | + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { |
| 418 | + return dt.timestamp_nanos_opt(); |
| 419 | + } |
| 420 | + |
| 421 | + // Try common space-separated format "YYYY-MM-DD HH:MM:SS[.frac]". |
| 422 | + let formats = [ |
| 423 | + "%Y-%m-%d %H:%M:%S%.f", |
| 424 | + "%Y-%m-%d %H:%M:%S", |
| 425 | + "%Y-%m-%dT%H:%M:%S%.f", |
| 426 | + "%Y-%m-%dT%H:%M:%S", |
| 427 | + ]; |
| 428 | + for fmt in &formats { |
| 429 | + if let Ok(ndt) = NaiveDateTime::parse_from_str(s, fmt) { |
| 430 | + return Utc.from_utc_datetime(&ndt).timestamp_nanos_opt(); |
| 431 | + } |
| 432 | + } |
| 433 | + |
| 434 | + None |
284 | 435 | } |
0 commit comments