Skip to content

Commit 54d2262

Browse files
committed
fix(mem): saturate memory release on underflow instead of panicking
BudgetGovernor::release() previously used fetch_sub which panics in debug builds (and wraps in release) when releasing more bytes than are currently allocated. This can occur legitimately during WAL replay, where samples are ingested without a prior reserve() call. Replace with a CAS loop that saturates to zero and emits a tracing::warn so the drift is observable without crashing the server. Also modernize if-let chains, Option combinators, and minor idioms across catalog, interpolate, window, eval, sql_plan_convert, collection_insert, prepared parser, kv hash_table, kv sorted_index, timeseries grouped scan, and trigger retry to use Rust 2024 let-chain syntax and is_none_or/is_some_and where appropriate.
1 parent 8cef87f commit 54d2262

12 files changed

Lines changed: 148 additions & 137 deletions

File tree

nodedb-lite/src/query/catalog.rs

Lines changed: 50 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -35,62 +35,62 @@ impl<S: StorageEngine> LiteCatalog<S> {
3535
impl<S: StorageEngine> SqlCatalog for LiteCatalog<S> {
3636
fn get_collection(&self, name: &str) -> Option<CollectionInfo> {
3737
// Check strict collections first.
38-
if let Ok(strict) = self.strict.lock() {
39-
if let Some(schema) = strict.schema(name) {
40-
let columns = schema
41-
.columns
42-
.iter()
43-
.map(|c| ColumnInfo {
44-
name: c.name.clone(),
45-
data_type: convert_column_type(&c.column_type),
46-
nullable: c.nullable,
47-
is_primary_key: c.primary_key,
48-
})
49-
.collect();
50-
let pk = schema
51-
.columns
52-
.iter()
53-
.find(|c| c.primary_key)
54-
.map(|c| c.name.clone());
55-
return Some(CollectionInfo {
56-
name: name.into(),
57-
engine: EngineType::DocumentStrict,
58-
columns,
59-
primary_key: pk,
60-
has_auto_tier: false,
61-
});
62-
}
38+
if let Ok(strict) = self.strict.lock()
39+
&& let Some(schema) = strict.schema(name)
40+
{
41+
let columns = schema
42+
.columns
43+
.iter()
44+
.map(|c| ColumnInfo {
45+
name: c.name.clone(),
46+
data_type: convert_column_type(&c.column_type),
47+
nullable: c.nullable,
48+
is_primary_key: c.primary_key,
49+
})
50+
.collect();
51+
let pk = schema
52+
.columns
53+
.iter()
54+
.find(|c| c.primary_key)
55+
.map(|c| c.name.clone());
56+
return Some(CollectionInfo {
57+
name: name.into(),
58+
engine: EngineType::DocumentStrict,
59+
columns,
60+
primary_key: pk,
61+
has_auto_tier: false,
62+
});
6363
}
6464

6565
// Check columnar collections.
66-
if let Ok(columnar) = self.columnar.lock() {
67-
if columnar.schema(name).is_some() {
68-
return Some(CollectionInfo {
69-
name: name.into(),
70-
engine: EngineType::Columnar,
71-
columns: Vec::new(),
72-
primary_key: None,
73-
has_auto_tier: false,
74-
});
75-
}
66+
if let Ok(columnar) = self.columnar.lock()
67+
&& columnar.schema(name).is_some()
68+
{
69+
return Some(CollectionInfo {
70+
name: name.into(),
71+
engine: EngineType::Columnar,
72+
columns: Vec::new(),
73+
primary_key: None,
74+
has_auto_tier: false,
75+
});
7676
}
7777

7878
// Check CRDT (schemaless) collections.
79-
if let Ok(crdt) = self.crdt.lock() {
80-
if crdt.collection_names().iter().any(|n| n == name) {
81-
return Some(CollectionInfo {
82-
name: name.into(),
83-
engine: EngineType::DocumentSchemaless,
84-
columns: vec![ColumnInfo {
85-
name: "id".into(),
86-
data_type: SqlDataType::String,
87-
nullable: false,
88-
is_primary_key: true,
89-
}],
90-
primary_key: Some("id".into()),
91-
has_auto_tier: false,
92-
});
93-
}
79+
if let Ok(crdt) = self.crdt.lock()
80+
&& crdt.collection_names().iter().any(|n| n == name)
81+
{
82+
return Some(CollectionInfo {
83+
name: name.into(),
84+
engine: EngineType::DocumentSchemaless,
85+
columns: vec![ColumnInfo {
86+
name: "id".into(),
87+
data_type: SqlDataType::String,
88+
nullable: false,
89+
is_primary_key: true,
90+
}],
91+
primary_key: Some("id".into()),
92+
has_auto_tier: false,
93+
});
9494
}
9595

9696
None

nodedb-mem/src/budget.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,32 @@ impl Budget {
7676
}
7777

7878
/// Release `size` bytes back to the budget.
79+
///
80+
/// Saturates to zero if `size` exceeds the current allocation (which can
81+
/// happen when data is replayed from WAL without a matching reservation).
7982
pub fn release(&self, size: usize) {
80-
let prev = self.allocated.fetch_sub(size, Ordering::Release);
81-
debug_assert!(
82-
prev >= size,
83-
"BUG: released more memory than allocated ({size} > {prev})"
84-
);
83+
loop {
84+
let current = self.allocated.load(Ordering::Acquire);
85+
let new_val = current.saturating_sub(size);
86+
match self.allocated.compare_exchange_weak(
87+
current,
88+
new_val,
89+
Ordering::Release,
90+
Ordering::Relaxed,
91+
) {
92+
Ok(_) => {
93+
if size > current {
94+
tracing::warn!(
95+
released = size,
96+
allocated = current,
97+
"memory release exceeds allocation (WAL replay or accounting drift)"
98+
);
99+
}
100+
return;
101+
}
102+
Err(_) => continue,
103+
}
104+
}
85105
}
86106

87107
/// Current allocated bytes.

nodedb-query/src/ts_functions/interpolate.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,19 +110,19 @@ fn fill_linear(values: &[Option<f64>], timestamps_ns: &[i64]) -> Vec<f64> {
110110
}
111111

112112
// Edge: fill leading NULLs with first known value.
113-
if let Some(first) = values.iter().position(|v| v.is_some()) {
114-
if let Some(fv) = values[first] {
115-
for r in result.iter_mut().take(first) {
116-
*r = fv;
117-
}
113+
if let Some(first) = values.iter().position(|v| v.is_some())
114+
&& let Some(fv) = values[first]
115+
{
116+
for r in result.iter_mut().take(first) {
117+
*r = fv;
118118
}
119119
}
120120
// Edge: fill trailing NULLs with last known value.
121-
if let Some(last) = values.iter().rposition(|v| v.is_some()) {
122-
if let Some(lv) = values[last] {
123-
for r in result.iter_mut().skip(last + 1) {
124-
*r = lv;
125-
}
121+
if let Some(last) = values.iter().rposition(|v| v.is_some())
122+
&& let Some(lv) = values[last]
123+
{
124+
for r in result.iter_mut().skip(last + 1) {
125+
*r = lv;
126126
}
127127
}
128128

nodedb-sql/src/planner/window.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ pub fn extract_window_functions(
2020
ast::SelectItem::ExprWithAlias { expr, alias } => (expr, normalize_ident(alias)),
2121
_ => continue,
2222
};
23-
if let ast::Expr::Function(func) = expr {
24-
if func.over.is_some() {
25-
specs.push(convert_window_spec(func, &alias)?);
26-
}
23+
if let ast::Expr::Function(func) = expr
24+
&& func.over.is_some()
25+
{
26+
specs.push(convert_window_spec(func, &alias)?);
2727
}
2828
}
2929
Ok(specs)

nodedb/src/control/planner/procedural/executor/eval.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,11 @@ fn try_eval_constant(sql: &str) -> Option<nodedb_types::Value> {
108108
let stmts = sqlparser::parser::Parser::parse_sql(&dialect, &select_sql).ok()?;
109109
let stmt = stmts.first()?;
110110

111-
if let sqlparser::ast::Statement::Query(query) = stmt {
112-
if let sqlparser::ast::SetExpr::Select(select) = &*query.body {
113-
if let Some(sqlparser::ast::SelectItem::UnnamedExpr(expr)) = select.projection.first() {
114-
return eval_expr(expr);
115-
}
116-
}
111+
if let sqlparser::ast::Statement::Query(query) = stmt
112+
&& let sqlparser::ast::SetExpr::Select(select) = &*query.body
113+
&& let Some(sqlparser::ast::SelectItem::UnnamedExpr(expr)) = select.projection.first()
114+
{
115+
return eval_expr(expr);
117116
}
118117
None
119118
}

nodedb/src/control/planner/sql_plan_convert.rs

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -227,22 +227,20 @@ fn convert_one(
227227
.collect();
228228

229229
// AUTO_TIER: split query across retention tiers if enabled.
230-
if *tiered {
231-
if let Some(registry) = &ctx.retention_registry {
232-
if let Some(policy) = registry.get(tenant_id.as_u32(), collection) {
233-
if policy.auto_tier {
234-
return Ok(super::auto_tier::plan_tiered_scan(
235-
&policy,
236-
tenant_id,
237-
*time_range,
238-
filter_bytes,
239-
group_by.clone(),
240-
agg_pairs,
241-
gap_fill.clone(),
242-
));
243-
}
244-
}
245-
}
230+
if *tiered
231+
&& let Some(registry) = &ctx.retention_registry
232+
&& let Some(policy) = registry.get(tenant_id.as_u32(), collection)
233+
&& policy.auto_tier
234+
{
235+
return Ok(super::auto_tier::plan_tiered_scan(
236+
&policy,
237+
tenant_id,
238+
*time_range,
239+
filter_bytes,
240+
group_by.clone(),
241+
agg_pairs,
242+
gap_fill.clone(),
243+
));
246244
}
247245

248246
let proj_names = extract_projection_names(projection);

nodedb/src/control/server/pgwire/ddl/collection_insert.rs

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -306,28 +306,28 @@ pub async fn insert_document(
306306

307307
// Track field names in catalog so DataFusion can resolve them in queries.
308308
// This makes schemaless fields visible for WHERE, GROUP BY, ORDER BY, etc.
309-
if let Some(catalog) = state.credentials.catalog() {
310-
if let Ok(Some(mut coll)) = catalog.get_collection(tenant_id.as_u32(), &parsed.coll_name) {
311-
let mut changed = false;
312-
for (name, val) in &fields {
313-
if name == "id" {
314-
continue;
315-
}
316-
if !coll.fields.iter().any(|(n, _)| n == name) {
317-
let type_str = match val {
318-
serde_json::Value::Number(n) if n.is_f64() => "FLOAT",
319-
serde_json::Value::Number(_) => "INT",
320-
serde_json::Value::Bool(_) => "BOOL",
321-
_ => "TEXT",
322-
};
323-
coll.fields.push((name.clone(), type_str.to_string()));
324-
changed = true;
325-
}
309+
if let Some(catalog) = state.credentials.catalog()
310+
&& let Ok(Some(mut coll)) = catalog.get_collection(tenant_id.as_u32(), &parsed.coll_name)
311+
{
312+
let mut changed = false;
313+
for (name, val) in &fields {
314+
if name == "id" {
315+
continue;
326316
}
327-
if changed {
328-
let _ = catalog.put_collection(&coll);
317+
if !coll.fields.iter().any(|(n, _)| n == name) {
318+
let type_str = match val {
319+
serde_json::Value::Number(n) if n.is_f64() => "FLOAT",
320+
serde_json::Value::Number(_) => "INT",
321+
serde_json::Value::Bool(_) => "BOOL",
322+
_ => "TEXT",
323+
};
324+
coll.fields.push((name.clone(), type_str.to_string()));
325+
changed = true;
329326
}
330327
}
328+
if changed {
329+
let _ = catalog.put_collection(&coll);
330+
}
331331
}
332332

333333
// Fire SYNC AFTER INSERT triggers (execute in write path, same transaction).

nodedb/src/control/server/pgwire/handler/prepared/parser.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,10 @@ impl QueryParser for NodeDbQueryParser {
110110
fn count_placeholders(sql: &str) -> usize {
111111
let mut max_idx = 0usize;
112112
for part in sql.split('$') {
113-
if let Some(num_str) = part.split(|c: char| !c.is_ascii_digit()).next() {
114-
if let Ok(idx) = num_str.parse::<usize>() {
115-
max_idx = max_idx.max(idx);
116-
}
113+
if let Some(num_str) = part.split(|c: char| !c.is_ascii_digit()).next()
114+
&& let Ok(idx) = num_str.parse::<usize>()
115+
{
116+
max_idx = max_idx.max(idx);
117117
}
118118
}
119119
max_idx

nodedb/src/engine/kv/hash_table.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,7 @@ impl KvHashTable {
209209
if let Some(idx) = Self::probe_find_index_static(&self.slots, h, key) {
210210
// probe_find_index_static guarantees slots[idx] is Some.
211211
let old_value = {
212-
let Some(slot) = self.slots[idx].as_ref() else {
213-
return None;
214-
};
212+
let slot = self.slots[idx].as_ref()?;
215213
let v = extract_value_from(&slot.value, &self.overflow);
216214
free_value_from(&slot.value, &mut self.overflow);
217215
v
@@ -228,9 +226,7 @@ impl KvHashTable {
228226
if let Some(old_slots) = self.rehash_source.as_mut()
229227
&& let Some(idx) = Self::probe_find_index_static(old_slots, h, key)
230228
{
231-
let Some(old_entry) = old_slots[idx].take() else {
232-
return None;
233-
};
229+
let old_entry = old_slots[idx].take()?;
234230
let old_value = extract_value_from(&old_entry.value, &self.overflow);
235231
free_value_from(&old_entry.value, &mut self.overflow);
236232
let new_kv_value = store_value_in(&mut self.overflow, value, self.inline_threshold);

nodedb/src/engine/kv/sorted_index/tree.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -433,16 +433,16 @@ impl OrderStatTree {
433433

434434
// Pruning: left subtree is all < current node.
435435
// Visit left if current > min (left might contain values in [min, current)).
436-
let go_left = min.map_or(true, |m| key > m);
436+
let go_left = min.is_none_or(|m| key > m);
437437
// Pruning: right subtree is all > current node.
438438
// Visit right if current < max (right might contain values in (current, max]).
439-
let go_right = max.map_or(true, |m| key < m);
439+
let go_right = max.is_none_or(|m| key < m);
440440

441441
if go_left {
442442
self.range_collect(n.left, min, max, result);
443443
}
444444

445-
let in_range = min.map_or(true, |m| key >= m) && max.map_or(true, |m| key <= m);
445+
let in_range = min.is_none_or(|m| key >= m) && max.is_none_or(|m| key <= m);
446446
if in_range {
447447
result.push((&n.sort_key, &n.primary_key));
448448
}

0 commit comments

Comments
 (0)