Skip to content

Commit 5783212

Browse files
authored
Merge pull request #16 from NodeDB-Lab/fix/timeseries-aggregate-null-and-time-bucket
fix: resolve null aggregates and broken time_bucket after SQL migration
2 parents 8588cf8 + 6187aee commit 5783212

7 files changed

Lines changed: 85 additions & 53 deletions

File tree

.github/workflows/test.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ jobs:
3232
run: |
3333
sudo apt-get update
3434
sudo apt-get install -y --no-install-recommends \
35-
cmake clang libclang-dev pkg-config protobuf-compiler perl
35+
cmake clang libclang-dev pkg-config protobuf-compiler perl \
36+
libcurl4-openssl-dev libsasl2-dev
3637
- name: Check formatting
3738
run: cargo fmt --all -- --check
3839
- name: Run clippy
@@ -52,6 +53,7 @@ jobs:
5253
run: |
5354
sudo apt-get update
5455
sudo apt-get install -y --no-install-recommends \
55-
cmake clang libclang-dev pkg-config protobuf-compiler perl
56+
cmake clang libclang-dev pkg-config protobuf-compiler perl \
57+
libcurl4-openssl-dev libsasl2-dev
5658
- name: Run tests
5759
run: cargo test --all-features

nodedb-lite/src/storage/encrypted.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ impl<S: StorageEngine> EncryptedStorage<S> {
151151
fn encrypt(&self, ns: Namespace, key: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, LiteError> {
152152
let nonce = Self::derive_nonce(ns, key);
153153
self.cipher
154-
.encrypt(Nonce::from_slice(&nonce), plaintext)
154+
.encrypt(&Nonce::from(nonce), plaintext)
155155
.map_err(|e| LiteError::Storage {
156156
detail: format!("AES-GCM encrypt failed: {e}"),
157157
})
@@ -160,7 +160,7 @@ impl<S: StorageEngine> EncryptedStorage<S> {
160160
fn decrypt(&self, ns: Namespace, key: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, LiteError> {
161161
let nonce = Self::derive_nonce(ns, key);
162162
self.cipher
163-
.decrypt(Nonce::from_slice(&nonce), ciphertext)
163+
.decrypt(&Nonce::from(nonce), ciphertext)
164164
.map_err(|e| LiteError::Storage {
165165
detail: format!("AES-GCM decrypt failed (wrong passphrase or corrupted data): {e}"),
166166
})

nodedb-sql/src/planner/aggregate.rs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -133,23 +133,33 @@ fn extract_bucket_interval(func: &ast::Function) -> Result<i64> {
133133
Ok(parse_interval_to_ms(&interval_str))
134134
}
135135

136-
/// Parse an interval string like "1h", "15m", "30s", "1d" to milliseconds.
136+
/// Parse an interval string to milliseconds.
137+
///
138+
/// Accepted forms: `"1h"`, `"15m"`, `"30s"`, `"1d"`, `"1 hour"`, `"15 minutes"`,
139+
/// `"30 seconds"`, `"7 days"`. Plural and singular word forms both work.
137140
fn parse_interval_to_ms(s: &str) -> i64 {
138141
let s = s.trim();
139142
if s.is_empty() {
140143
return 0;
141144
}
142-
let (num_str, suffix) = s.split_at(s.len() - 1);
143-
let num: i64 = num_str.trim().parse().unwrap_or(0);
144-
match suffix {
145-
"s" => num * 1_000,
146-
"m" => num * 60_000,
147-
"h" => num * 3_600_000,
148-
"d" => num * 86_400_000,
149-
_ => {
150-
// Try full string as seconds.
151-
s.parse::<i64>().unwrap_or(0) * 1_000
145+
146+
// Split into numeric part and unit part (handles both "1h" and "1 hour").
147+
let num_end = s
148+
.find(|c: char| !c.is_ascii_digit() && c != '.')
149+
.unwrap_or(s.len());
150+
let num: i64 = s[..num_end].trim().parse().unwrap_or(0);
151+
let unit = s[num_end..].trim();
152+
153+
match unit {
154+
"s" | "sec" | "second" | "seconds" => num * 1_000,
155+
"m" | "min" | "minute" | "minutes" => num * 60_000,
156+
"h" | "hr" | "hour" | "hours" => num * 3_600_000,
157+
"d" | "day" | "days" => num * 86_400_000,
158+
"" => {
159+
// Bare number — treat as seconds.
160+
num * 1_000
152161
}
162+
_ => 0,
153163
}
154164
}
155165

@@ -260,5 +270,12 @@ mod tests {
260270
assert_eq!(parse_interval_to_ms("15m"), 900_000);
261271
assert_eq!(parse_interval_to_ms("30s"), 30_000);
262272
assert_eq!(parse_interval_to_ms("7d"), 604_800_000);
273+
// Word-form intervals.
274+
assert_eq!(parse_interval_to_ms("1 hour"), 3_600_000);
275+
assert_eq!(parse_interval_to_ms("2 hours"), 7_200_000);
276+
assert_eq!(parse_interval_to_ms("15 minutes"), 900_000);
277+
assert_eq!(parse_interval_to_ms("30 seconds"), 30_000);
278+
assert_eq!(parse_interval_to_ms("1 day"), 86_400_000);
279+
assert_eq!(parse_interval_to_ms("5 min"), 300_000);
263280
}
264281
}

nodedb-wal/src/crypto.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
//! `payload_len` includes the 16-byte auth tag.
1515
1616
use aes_gcm::Aes256Gcm;
17-
use aes_gcm::aead::generic_array::GenericArray;
1817
use aes_gcm::aead::{Aead, KeyInit};
1918

2019
use crate::error::{Result, WalError};
@@ -192,7 +191,7 @@ pub const AUTH_TAG_SIZE: usize = 16;
192191
fn lsn_to_nonce(lsn: u64) -> aes_gcm::Nonce<aes_gcm::aead::consts::U12> {
193192
let mut nonce_bytes = [0u8; 12];
194193
nonce_bytes[..8].copy_from_slice(&lsn.to_le_bytes());
195-
*GenericArray::from_slice(&nonce_bytes)
194+
nonce_bytes.into()
196195
}
197196

198197
#[cfg(test)]

nodedb/src/control/planner/sql_plan_convert.rs

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,8 @@ fn convert_one(
249249
tiered,
250250
} => {
251251
let filter_bytes = serialize_filters(filters)?;
252-
let agg_pairs: Vec<(String, String)> = aggregates
253-
.iter()
254-
.map(|a| (a.function.clone(), a.alias.clone()))
255-
.collect();
252+
let agg_pairs: Vec<(String, String)> =
253+
aggregates.iter().map(agg_expr_to_pair).collect();
256254

257255
// AUTO_TIER: split query across retention tiers if enabled.
258256
if *tiered
@@ -667,10 +665,7 @@ fn convert_aggregate(
667665
let vshard = VShardId::from_collection(&left_collection);
668666

669667
let group_strs = group_by_to_strings(group_by);
670-
let agg_pairs = aggregates
671-
.iter()
672-
.map(|a| (a.function.clone(), a.alias.clone()))
673-
.collect();
668+
let agg_pairs = aggregates.iter().map(agg_expr_to_pair).collect();
674669

675670
return Ok(vec![PhysicalTask {
676671
tenant_id,
@@ -700,10 +695,7 @@ fn convert_aggregate(
700695
let having_bytes = serialize_filters(having)?;
701696

702697
let group_strs = group_by_to_strings(group_by);
703-
let agg_pairs = aggregates
704-
.iter()
705-
.map(|a| (a.function.clone(), a.alias.clone()))
706-
.collect();
698+
let agg_pairs = aggregates.iter().map(agg_expr_to_pair).collect();
707699

708700
Ok(vec![PhysicalTask {
709701
tenant_id,
@@ -734,6 +726,24 @@ fn extract_collection_name(plan: &SqlPlan) -> String {
734726
}
735727
}
736728

729+
/// Convert an `AggregateExpr` to the `(op, field)` pair the executor expects.
730+
///
731+
/// The field is extracted from the first argument (e.g. `elapsed_ms` from
732+
/// `AVG(elapsed_ms)`). Wildcard args produce `"*"`. Falls back to `"*"` when
733+
/// there are no arguments (bare `COUNT`).
734+
fn agg_expr_to_pair(a: &AggregateExpr) -> (String, String) {
735+
let field = a
736+
.args
737+
.first()
738+
.map(|arg| match arg {
739+
SqlExpr::Column { name, .. } => name.clone(),
740+
SqlExpr::Wildcard => "*".into(),
741+
_ => format!("{arg:?}"),
742+
})
743+
.unwrap_or_else(|| "*".into());
744+
(a.function.clone(), field)
745+
}
746+
737747
fn group_by_to_strings(exprs: &[SqlExpr]) -> Vec<String> {
738748
exprs
739749
.iter()

nodedb/src/control/security/encryption.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,11 @@ impl VolumeEncryption {
185185
let cipher = Aes256Gcm::new_from_slice(master).map_err(|e| crate::Error::Encryption {
186186
detail: format!("AES-GCM key init failed: {e}"),
187187
})?;
188-
let nonce = Nonce::from_slice(&nonce_bytes);
188+
let nonce = Nonce::from(nonce_bytes);
189189

190190
let ciphertext =
191191
cipher
192-
.encrypt(nonce, dek.as_ref())
192+
.encrypt(&nonce, dek.as_ref())
193193
.map_err(|e| crate::Error::Encryption {
194194
detail: format!("DEK encryption failed: {e}"),
195195
})?;
@@ -230,11 +230,16 @@ impl VolumeEncryption {
230230
let cipher = Aes256Gcm::new_from_slice(master).map_err(|e| crate::Error::Encryption {
231231
detail: format!("AES-GCM key init failed: {e}"),
232232
})?;
233-
let nonce = Nonce::from_slice(nonce_bytes);
233+
let nonce_arr: [u8; 12] = nonce_bytes
234+
.try_into()
235+
.map_err(|_| crate::Error::Encryption {
236+
detail: "nonce slice is not 12 bytes".into(),
237+
})?;
238+
let nonce = Nonce::from(nonce_arr);
234239

235240
let plaintext =
236241
cipher
237-
.decrypt(nonce, ciphertext)
242+
.decrypt(&nonce, ciphertext)
238243
.map_err(|_| crate::Error::Encryption {
239244
detail: "DEK decryption failed: authentication tag mismatch".into(),
240245
})?;

nodedb/src/event/kafka/producer.rs

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,14 @@ pub fn spawn_kafka_task(
6666
};
6767

6868
// Initialize transactional producer if configured.
69-
if config.transactional {
70-
if let Err(e) = producer.init_transactions(Duration::from_secs(10)) {
71-
warn!(
72-
stream = %stream_name,
73-
error = %e,
74-
"failed to init Kafka transactions — falling back to at-least-once"
75-
);
76-
}
69+
if config.transactional
70+
&& let Err(e) = producer.init_transactions(Duration::from_secs(10))
71+
{
72+
warn!(
73+
stream = %stream_name,
74+
error = %e,
75+
"failed to init Kafka transactions — falling back to at-least-once"
76+
);
7777
}
7878

7979
let group_name = format!("_kafka_{stream_name}");
@@ -99,11 +99,11 @@ pub fn spawn_kafka_task(
9999
let batch_size = events.events.len();
100100

101101
// Begin transaction if configured.
102-
if config.transactional {
103-
if let Err(e) = producer.begin_transaction() {
104-
warn!(error = %e, "Kafka begin_transaction failed");
105-
continue;
106-
}
102+
if config.transactional
103+
&& let Err(e) = producer.begin_transaction()
104+
{
105+
warn!(error = %e, "Kafka begin_transaction failed");
106+
continue;
107107
}
108108

109109
let mut published = 0u32;
@@ -136,13 +136,12 @@ pub fn spawn_kafka_task(
136136
}
137137

138138
// Commit Kafka transaction.
139-
if config.transactional && published > 0 {
140-
if let Err(e) = producer
139+
if config.transactional && published > 0
140+
&& let Err(e) = producer
141141
.commit_transaction(Duration::from_secs(10))
142-
{
143-
warn!(error = %e, "Kafka commit_transaction failed");
144-
continue;
145-
}
142+
{
143+
warn!(error = %e, "Kafka commit_transaction failed");
144+
continue;
146145
}
147146

148147
// Commit consumer offsets for successfully published events.
@@ -189,7 +188,7 @@ fn create_producer(
189188

190189
if config.transactional {
191190
client_config.set("enable.idempotence", "true");
192-
client_config.set("transactional.id", &format!("nodedb-kafka-{stream_name}"));
191+
client_config.set("transactional.id", format!("nodedb-kafka-{stream_name}"));
193192
}
194193

195194
client_config

0 commit comments

Comments
 (0)