Skip to content

Commit 5ca9cf9

Browse files
committed
feat(timeseries): add JSON ingest path and fix WAL replay memory accounting
Add execute_json_ingest() to the timeseries ingest handler, enabling SQL INSERT to write rows as JSON arrays that are converted to ILP lines before ingestion. The measurement name is extracted from the scoped collection name and validated against ILP character rules. Fix a memory accounting bug in WAL replay: samples accepted during replay were not reserved in the memory governor, causing the governor balance to go negative on every subsequent flush. The handler now calls try_reserve() after each successful replay batch to keep allocations in sync with releases. Add chrono as a workspace dependency (required by the JSON→ILP timestamp conversion in the new ingest path).
1 parent 7f96102 commit 5ca9cf9

4 files changed

Lines changed: 170 additions & 1 deletion

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ toml = "0.8"
138138
anyhow = "1"
139139
serde_json = "1"
140140
sonic-rs = "0.5"
141+
chrono = { version = "0.4", features = ["std"], default-features = false }
141142

142143
# MessagePack
143144
rmpv = "1"

nodedb/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ toml = { workspace = true }
5252
anyhow = { workspace = true }
5353
serde_json = { workspace = true }
5454
sonic-rs = { workspace = true }
55+
chrono = { workspace = true }
5556
redb = { workspace = true }
5657
crc32c = { workspace = true }
5758
zerompk = { workspace = true }

nodedb/src/data/executor/handlers/timeseries/ingest.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
33
use std::collections::HashMap;
44

5+
use sonic_rs::{JsonContainerTrait, JsonValueTrait};
6+
57
use crate::bridge::envelope::{ErrorCode, Payload, Response, Status};
68
use crate::data::executor::core_loop::CoreLoop;
79
use crate::data::executor::response_codec;
@@ -73,6 +75,7 @@ impl CoreLoop {
7375

7476
match format {
7577
"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),
7679
_ => self.response_error(
7780
task,
7881
ErrorCode::Internal {
@@ -281,4 +284,152 @@ impl CoreLoop {
281284
error_code: None,
282285
}
283286
}
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
284435
}

nodedb/src/data/executor/handlers/timeseries_wal.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ impl CoreLoop {
138138
&mut series_keys,
139139
now_ms,
140140
);
141+
142+
// Reserve memory in the governor to match what was replayed,
143+
// so that subsequent flush_ts_collection releases stay balanced.
144+
if accepted > 0
145+
&& let Some(ref gov) = self.governor
146+
{
147+
let _ = gov.try_reserve(nodedb_mem::EngineId::Timeseries, accepted * 24);
148+
}
149+
141150
replayed += accepted;
142151
} else {
143152
// Binary payload — try msgpack-encoded samples.
@@ -158,7 +167,14 @@ impl CoreLoop {
158167
},
159168
);
160169
}
161-
replayed += batch.samples.len();
170+
let sample_count = batch.samples.len();
171+
if sample_count > 0
172+
&& let Some(ref gov) = self.governor
173+
{
174+
let _ =
175+
gov.try_reserve(nodedb_mem::EngineId::Timeseries, sample_count * 24);
176+
}
177+
replayed += sample_count;
162178
}
163179
}
164180
}

0 commit comments

Comments
 (0)