Skip to content

Commit 70135e3

Browse files
committed
feat(engine): add memory-pressure backpressure guards and wire durable outbound queues
Write-path changes across document, vector, batch, and query layers: - Reject writes with `LiteError::Backpressure` when the memory governor reports `PressureLevel::Critical` in `document_put_impl`, `vector_insert_impl`, `document_put_with_vector_batch_impl`, and the batch vector ingest path. - Wire all outbound enqueue calls through `reconcile_outbound_enqueue` so backpressure errors are uniformly logged and propagated. - FTS and spatial enqueue calls switched from direct durable enqueue to the new staging path (`stage_index`, `stage_delete`, `stage_insert`); the flush task spills staged entries to durable storage periodically. - `NodeDbLite::open_inner` wires `StreamSeqTracker`, loads the Lite identity (`lite_id` + `epoch`) for the handshake, opens all outbound queues with the configured cap, and wires FTS/spatial outbound queues into the query engine for SQL-path writes. - `flush()` now drains the KV write buffer first and spills FTS/spatial staging buffers to durable queues on each flush tick. - Add `LiteError::Backpressure` variant; fix typo in SPDX identifiers.
1 parent 847821f commit 70135e3

30 files changed

Lines changed: 838 additions & 251 deletions

File tree

nodedb-lite/src/error.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ pub enum LiteError {
4444
/// failure that cannot be recovered automatically (OPFS has no rename).
4545
#[error("OPFS worker bridge failed: {detail}")]
4646
WorkerFailed { detail: String },
47+
48+
/// An error during key derivation, salt I/O, or encryption setup.
49+
#[error("encryption error: {detail}")]
50+
Encryption { detail: String },
4751
}
4852

4953
impl From<nodedb_types::columnar::SchemaError> for LiteError {
@@ -80,4 +84,26 @@ mod tests {
8084
let ndb: nodedb_types::error::NodeDbError = e.into();
8185
assert!(ndb.to_string().contains("test"));
8286
}
87+
88+
#[test]
89+
fn lite_error_encryption_display_and_convert() {
90+
let e = LiteError::Encryption {
91+
detail: "argon2 key derivation failed".into(),
92+
};
93+
let rendered = e.to_string();
94+
assert!(rendered.contains("encryption error"));
95+
assert!(rendered.contains("argon2 key derivation failed"));
96+
97+
let ndb: nodedb_types::error::NodeDbError = e.into();
98+
assert!(ndb.to_string().contains("argon2 key derivation failed"));
99+
}
100+
101+
#[test]
102+
fn lite_error_backpressure_display() {
103+
let e = LiteError::Backpressure {
104+
detail: "outbound queue full".into(),
105+
};
106+
assert!(e.to_string().contains("backpressure"));
107+
assert!(e.to_string().contains("outbound queue full"));
108+
}
83109
}

nodedb-lite/src/nodedb/batch.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ impl<S: StorageEngine> NodeDbLite<S> {
2424
return Ok(());
2525
}
2626

27+
if self.governor.pressure() == crate::memory::PressureLevel::Critical {
28+
return Err(NodeDbError::storage(
29+
crate::error::LiteError::Backpressure {
30+
detail: "batch vector insert rejected: memory governor is at Critical pressure"
31+
.into(),
32+
},
33+
));
34+
}
35+
2736
let dim = vectors[0].1.len();
2837

2938
{

nodedb-lite/src/nodedb/collection/kv.rs

Lines changed: 33 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ impl<S: StorageEngine> NodeDbLite<S> {
114114
value: &[u8],
115115
deadline_ms: u64,
116116
) -> NodeDbResult<()> {
117+
if self.governor.pressure() == crate::memory::PressureLevel::Critical {
118+
return Err(nodedb_types::error::NodeDbError::storage(
119+
crate::error::LiteError::Backpressure {
120+
detail: "KV write rejected: memory governor is at Critical pressure".into(),
121+
},
122+
));
123+
}
124+
117125
let rkey = kv_key(collection, key.as_bytes());
118126
let encoded = encode_value(deadline_ms, value);
119127

@@ -126,10 +134,7 @@ impl<S: StorageEngine> NodeDbLite<S> {
126134
key: rkey.clone(),
127135
value: encoded,
128136
});
129-
let n = buf.ops.len();
130-
self.kv_overlay_len
131-
.store(buf.overlay.len(), std::sync::atomic::Ordering::Release);
132-
n >= KV_FLUSH_THRESHOLD
137+
buf.ops.len() >= KV_FLUSH_THRESHOLD
133138
};
134139

135140
// Invalidate any cached value for this key so subsequent reads go to storage.
@@ -166,34 +171,25 @@ impl<S: StorageEngine> NodeDbLite<S> {
166171
pub async fn kv_get(&self, collection: &str, key: &str) -> NodeDbResult<Option<Vec<u8>>> {
167172
let rkey = kv_key(collection, key.as_bytes());
168173

169-
// Fast path: when the overlay is empty (common case in read-heavy
170-
// workloads between flushes), skip the mutex acquire entirely and
171-
// go straight to storage. The single-writer design + Release stores
172-
// on overlay mutation make this safe: observing `len == 0` means
173-
// the writer either hasn't started or has completed; either way the
174-
// overlay holds no relevant entry.
175-
if self
176-
.kv_overlay_len
177-
.load(std::sync::atomic::Ordering::Acquire)
178-
> 0
179-
{
180-
// Scope the guard so it is not live at any await point.
181-
let overlay_result: Option<Option<Vec<u8>>> = {
182-
let buf = self.kv_write_buf.lock_or_recover();
183-
buf.overlay.get(&rkey).map(|entry| match entry {
184-
Some(stored) => decode_value(stored).and_then(|(deadline, user_bytes)| {
185-
if is_expired(deadline) {
186-
None
187-
} else {
188-
Some(user_bytes.to_vec())
189-
}
190-
}),
191-
None => None,
192-
})
193-
};
194-
if let Some(result) = overlay_result {
195-
return Ok(result);
196-
}
174+
// Always acquire the write-buffer lock to check the overlay first.
175+
// This prevents torn reads that could occur if an unconditional
176+
// Acquire load of a length counter raced with a concurrent writer.
177+
// Scope the guard so it is not live at any await point.
178+
let overlay_result: Option<Option<Vec<u8>>> = {
179+
let buf = self.kv_write_buf.lock_or_recover();
180+
buf.overlay.get(&rkey).map(|entry| match entry {
181+
Some(stored) => decode_value(stored).and_then(|(deadline, user_bytes)| {
182+
if is_expired(deadline) {
183+
None
184+
} else {
185+
Some(user_bytes.to_vec())
186+
}
187+
}),
188+
None => None,
189+
})
190+
};
191+
if let Some(result) = overlay_result {
192+
return Ok(result);
197193
}
198194

199195
// Cache check: look up the composite key before hitting storage.
@@ -260,10 +256,7 @@ impl<S: StorageEngine> NodeDbLite<S> {
260256
ns: Namespace::Kv,
261257
key: rkey.clone(),
262258
});
263-
let n = buf.ops.len();
264-
self.kv_overlay_len
265-
.store(buf.overlay.len(), std::sync::atomic::Ordering::Release);
266-
n >= KV_FLUSH_THRESHOLD
259+
buf.ops.len() >= KV_FLUSH_THRESHOLD
267260
};
268261
// Evict the expired entry so future reads don't serve stale data.
269262
{
@@ -286,10 +279,7 @@ impl<S: StorageEngine> NodeDbLite<S> {
286279
ns: Namespace::Kv,
287280
key: rkey.clone(),
288281
});
289-
let n = buf.ops.len();
290-
self.kv_overlay_len
291-
.store(buf.overlay.len(), std::sync::atomic::Ordering::Release);
292-
n >= KV_FLUSH_THRESHOLD
282+
buf.ops.len() >= KV_FLUSH_THRESHOLD
293283
};
294284

295285
// Invalidate the cache so subsequent reads don't return stale data.
@@ -405,10 +395,7 @@ impl<S: StorageEngine> NodeDbLite<S> {
405395
key: rkey.clone(),
406396
});
407397
}
408-
let n = buf.ops.len();
409-
self.kv_overlay_len
410-
.store(buf.overlay.len(), std::sync::atomic::Ordering::Release);
411-
n >= KV_FLUSH_THRESHOLD
398+
buf.ops.len() >= KV_FLUSH_THRESHOLD
412399
};
413400
// Evict expired keys from the cache.
414401
{
@@ -543,16 +530,15 @@ impl<S: StorageEngine> NodeDbLite<S> {
543530
}
544531

545532
/// Internal: flush write buffer to storage without touching CRDT.
546-
async fn kv_flush_inner(&self) -> NodeDbResult<usize> {
533+
/// `pub(in crate::nodedb)` so the global `flush()` can drain the KV buffer.
534+
pub(in crate::nodedb) async fn kv_flush_inner(&self) -> NodeDbResult<usize> {
547535
let ops: Vec<WriteOp> = {
548536
let mut buf = self.kv_write_buf.lock_or_recover();
549537
if buf.ops.is_empty() {
550538
return Ok(0);
551539
}
552540
let ops = std::mem::take(&mut buf.ops);
553541
buf.overlay.clear();
554-
self.kv_overlay_len
555-
.store(0, std::sync::atomic::Ordering::Release);
556542
ops
557543
};
558544

nodedb-lite/src/nodedb/core/flush.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ use super::types::{
1717
impl<S: StorageEngine> NodeDbLite<S> {
1818
/// Persist all in-memory state to storage (call before shutdown).
1919
pub async fn flush(&self) -> NodeDbResult<()> {
20+
// Drain the buffered KV writes first — they have their own batch-commit
21+
// path. Without this, `flush()` (and the auto-flush timer) would not
22+
// persist KV `put`s, contradicting "persist all in-memory state".
23+
self.kv_flush_inner().await?;
24+
2025
let mut ops = Vec::new();
2126

2227
// ── Persist CRDT snapshot (CRC32C wrapped) ──
@@ -83,6 +88,8 @@ impl<S: StorageEngine> NodeDbLite<S> {
8388
value: names_bytes,
8489
});
8590

91+
// Mutated only via the native segment-ext path, compiled out on wasm32.
92+
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
8693
let mut segment_data = Vec::new();
8794
for (name, index) in csr_map.iter() {
8895
match index.checkpoint_to_bytes() {
@@ -180,6 +187,8 @@ impl<S: StorageEngine> NodeDbLite<S> {
180187
value: names_bytes,
181188
});
182189

190+
// Mutated only via the native segment-ext path, compiled out on wasm32.
191+
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
183192
let mut segment_data = Vec::new();
184193
for (name, index) in indices.iter() {
185194
let key = format!("hnsw:{name}");
@@ -289,6 +298,27 @@ impl<S: StorageEngine> NodeDbLite<S> {
289298
.await
290299
.map_err(|e| NodeDbError::storage(format!("fts flush: {e}")))?;
291300

301+
// ── Spill FTS + spatial staging buffers to durable queues ────────────
302+
// These queues accumulate sync entries written synchronously by
303+
// `index_document_text`, `remove_document_text`, `spatial_insert`, and
304+
// `spatial_delete`. Spilling here (async, ~every second) keeps the
305+
// staging buffers bounded and ensures entries are durable before the
306+
// next sync transport drain.
307+
#[cfg(not(target_arch = "wasm32"))]
308+
if let Some(q) = &self.fts_outbound
309+
&& let Err(e) = q.flush_staging().await
310+
{
311+
tracing::warn!(error = %e, "fts outbound flush_staging failed; \
312+
staged entries remain and will be retried on next flush");
313+
}
314+
#[cfg(not(target_arch = "wasm32"))]
315+
if let Some(q) = &self.spatial_outbound
316+
&& let Err(e) = q.flush_staging().await
317+
{
318+
tracing::warn!(error = %e, "spatial outbound flush_staging failed; \
319+
staged entries remain and will be retried on next flush");
320+
}
321+
292322
Ok(())
293323
}
294324
}

0 commit comments

Comments
 (0)