use crate::error::DbError;
use crate::storage::collection::Collection;
use serde_json::Value;
pub const MAX_EXPORT_LINE_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_EXPORT_BLOB_CHUNK_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, PartialEq)]
pub enum ExportItem {
Doc(Value),
BlobChunk {
key: String,
index: u32,
data: Vec<u8>,
},
}
pub struct ExportStreamParser {
buf: Vec<u8>,
pending_blob: Option<(String, u32, usize)>,
max_line: usize,
pub malformed_lines: usize,
}
impl Default for ExportStreamParser {
fn default() -> Self {
Self::new(MAX_EXPORT_LINE_BYTES)
}
}
impl ExportStreamParser {
pub fn new(max_line: usize) -> Self {
Self {
buf: Vec::new(),
pending_blob: None,
max_line,
malformed_lines: 0,
}
}
pub fn feed(&mut self, chunk: &[u8]) -> Result<Vec<ExportItem>, String> {
self.buf.extend_from_slice(chunk);
let mut out = Vec::new();
let mut pos = 0usize;
loop {
if let Some((_, _, len)) = self.pending_blob {
if self.buf.len() - pos < len {
break; }
let (key, index, _) = self.pending_blob.take().expect("checked above");
let data = self.buf[pos..pos + len].to_vec();
pos += len;
if self.buf.get(pos) == Some(&b'\n') {
pos += 1;
}
out.push(ExportItem::BlobChunk { key, index, data });
continue;
}
let Some(nl) = self.buf[pos..].iter().position(|b| *b == b'\n') else {
if self.buf.len() - pos > self.max_line {
return Err(format!(
"export line exceeds {} bytes without a newline",
self.max_line
));
}
break;
};
let end = pos + nl;
if nl > self.max_line {
return Err(format!(
"export line of {} bytes exceeds {}",
nl, self.max_line
));
}
let line_range = pos..end;
pos = end + 1;
let line = self.buf[line_range].to_vec();
self.handle_line(&line, &mut out)?;
}
self.buf.drain(..pos);
Ok(out)
}
pub fn finish(mut self) -> Result<Vec<ExportItem>, String> {
if let Some((key, _, len)) = &self.pending_blob {
return Err(format!(
"export stream ended inside blob chunk for {} ({} of {} bytes)",
key,
self.buf.len(),
len
));
}
let mut out = Vec::new();
let rest = std::mem::take(&mut self.buf);
self.handle_line(&rest, &mut out)?;
if self.pending_blob.is_some() {
return Err("export stream ended after a blob chunk header".to_string());
}
Ok(out)
}
fn handle_line(&mut self, line: &[u8], out: &mut Vec<ExportItem>) -> Result<(), String> {
let line = line.trim_ascii();
if line.is_empty() {
return Ok(());
}
let doc: Value = match serde_json::from_slice(line) {
Ok(v) => v,
Err(_) => {
self.malformed_lines += 1;
return Ok(());
}
};
let is_blob_chunk = doc.get("_type").and_then(|t| t.as_str()) == Some("blob_chunk");
if !is_blob_chunk {
out.push(ExportItem::Doc(doc));
return Ok(());
}
let key = doc.get("_doc_key").and_then(|s| s.as_str());
let index = doc.get("_chunk_index").and_then(|n| n.as_u64());
let (Some(key), Some(index)) = (key, index) else {
self.malformed_lines += 1;
return Ok(());
};
let index = u32::try_from(index).map_err(|_| "blob chunk index out of range")?;
if let Some(b64) = doc.get("_blob_data").and_then(|s| s.as_str()) {
use base64::{engine::general_purpose, Engine as _};
match general_purpose::STANDARD.decode(b64) {
Ok(data) => out.push(ExportItem::BlobChunk {
key: key.to_string(),
index,
data,
}),
Err(_) => self.malformed_lines += 1,
}
return Ok(());
}
if let Some(len) = doc.get("_data_length").and_then(|n| n.as_u64()) {
let len = usize::try_from(len).unwrap_or(usize::MAX);
if len > MAX_EXPORT_BLOB_CHUNK_BYTES {
return Err(format!(
"blob chunk of {} bytes exceeds {}",
len, MAX_EXPORT_BLOB_CHUNK_BYTES
));
}
self.pending_blob = Some((key.to_string(), index, len));
return Ok(());
}
self.malformed_lines += 1;
Ok(())
}
}
pub async fn import_export_response(
mut resp: reqwest::Response,
coll: &Collection,
) -> Result<usize, DbError> {
const BATCH: usize = 1000;
let mut parser = ExportStreamParser::default();
let mut batch_docs: Vec<(String, Value)> = Vec::with_capacity(BATCH);
let mut total_copied = 0usize;
fn apply(items: Vec<ExportItem>, coll: &Collection, batch_docs: &mut Vec<(String, Value)>) {
for item in items {
match item {
ExportItem::BlobChunk { key, index, data } => {
if let Err(e) = coll.put_blob_chunk(&key, index, &data) {
tracing::error!("HEAL: Failed to write chunk {} for {}: {}", index, key, e);
}
}
ExportItem::Doc(mut doc) => {
if let Some(obj) = doc.as_object_mut() {
obj.remove("_database");
obj.remove("_collection");
obj.remove("_shardConfig");
}
let key = doc
.get("_key")
.and_then(|k| k.as_str())
.unwrap_or("")
.to_string();
if !key.is_empty() {
batch_docs.push((key, doc));
}
}
}
}
}
fn flush(coll: &Collection, batch_docs: &mut Vec<(String, Value)>, total: &mut usize) {
if batch_docs.is_empty() {
return;
}
let count = batch_docs.len();
if let Err(e) = coll.upsert_batch(std::mem::take(batch_docs)) {
tracing::error!("HEAL: Batch upsert failed: {}", e);
} else {
*total += count;
}
}
loop {
let chunk = match resp.chunk().await {
Ok(Some(c)) => c,
Ok(None) => break,
Err(e) => {
flush(coll, &mut batch_docs, &mut total_copied);
return Err(DbError::InternalError(format!(
"Export stream failed after {} docs: {}",
total_copied, e
)));
}
};
let items = parser
.feed(&chunk)
.map_err(|e| DbError::InternalError(format!("Export stream rejected: {}", e)))?;
apply(items, coll, &mut batch_docs);
if batch_docs.len() >= BATCH {
flush(coll, &mut batch_docs, &mut total_copied);
}
}
let malformed = parser.malformed_lines;
let items = parser
.finish()
.map_err(|e| DbError::InternalError(format!("Export stream rejected: {}", e)))?;
apply(items, coll, &mut batch_docs);
flush(coll, &mut batch_docs, &mut total_copied);
if malformed > 0 {
tracing::warn!("HEAL: skipped {} malformed export line(s)", malformed);
}
Ok(total_copied)
}
#[cfg(test)]
mod tests {
use super::*;
fn feed_all(chunks: &[&[u8]]) -> Vec<ExportItem> {
let mut p = ExportStreamParser::new(1024);
let mut out = Vec::new();
for c in chunks {
out.extend(p.feed(c).unwrap());
}
out.extend(p.finish().unwrap());
out
}
#[test]
fn a_multibyte_character_split_across_chunks_survives() {
let line = "{\"_key\":\"k\",\"v\":\"caf\u{e9}\"}\n".as_bytes();
let split = line.iter().position(|b| *b == 0xC3).unwrap() + 1;
let out = feed_all(&[&line[..split], &line[split..]]);
assert_eq!(
out,
vec![ExportItem::Doc(
serde_json::json!({"_key": "k", "v": "café"})
)]
);
}
#[test]
fn a_final_line_without_newline_is_decoded() {
let out = feed_all(&[b"{\"_key\":\"a\"}\n{\"_key\"", b":\"b\"}"]);
assert_eq!(out.len(), 2);
}
#[test]
fn an_endless_line_is_refused() {
let mut p = ExportStreamParser::new(16);
assert!(p.feed(&[b'x'; 17]).is_err());
}
#[test]
fn raw_blob_chunks_are_read_by_length() {
let header = b"{\"_type\":\"blob_chunk\",\"_doc_key\":\"b\",\"_chunk_index\":0,\"_data_length\":4}\n";
let mut stream = header.to_vec();
stream.extend_from_slice(&[b'\n', 0xff, 0x00, b'\n']); stream.push(b'\n');
stream.extend_from_slice(b"{\"_key\":\"b\"}\n");
let chunks: Vec<&[u8]> = stream.chunks(1).collect();
let out = feed_all(&chunks);
assert_eq!(
out,
vec![
ExportItem::BlobChunk {
key: "b".into(),
index: 0,
data: vec![b'\n', 0xff, 0x00, b'\n']
},
ExportItem::Doc(serde_json::json!({"_key": "b"})),
]
);
}
#[test]
fn a_stream_cut_inside_a_blob_chunk_is_an_error() {
let mut p = ExportStreamParser::new(1024);
p.feed(b"{\"_type\":\"blob_chunk\",\"_doc_key\":\"b\",\"_chunk_index\":0,\"_data_length\":8}\nab")
.unwrap();
assert!(p.finish().is_err());
}
}