use std::collections::BTreeMap;
use prost::Message;
use crate::hfile::block::BLOCK_HEADER_SIZE;
use crate::hfile::block_type::HFileBlockType;
use crate::hfile::error::{HFileError, Result};
use crate::hfile::proto::{BytesBytesPair, InfoProto, TrailerProto};
use crate::hfile::trailer::TRAILER_SIZE;
const CHECKSUM_TYPE_CRC32: u8 = 2;
const BYTES_PER_CHECKSUM: usize = 16 * 1024;
pub struct HFileWriter;
impl HFileWriter {
pub fn write(
entries: &[(String, Vec<u8>)],
file_info: BTreeMap<String, Vec<u8>>,
) -> Result<Vec<u8>> {
let mut entries: Vec<&(String, Vec<u8>)> = entries.iter().collect();
entries.sort_by(|left, right| left.0.cmp(&right.0));
let mut builder = HFileBuilder::new(usize::MAX);
for (key, value) in entries {
builder.append(key.as_bytes(), value)?;
}
builder.finish(file_info)
}
}
pub struct HFileBuilder {
block_size: usize,
output: Vec<u8>,
data: Vec<u8>,
index: Vec<(u64, u32, Vec<u8>)>,
first_key_of_block: Option<Vec<u8>>,
last_key_content: Vec<u8>,
last_data_block_offset: i64,
total_uncompressed_bytes: u64,
entry_count: u64,
}
impl HFileBuilder {
pub fn new(block_size: usize) -> Self {
Self {
block_size: block_size.max(1),
output: Vec::new(),
data: Vec::new(),
index: Vec::new(),
first_key_of_block: None,
last_key_content: Vec::new(),
last_data_block_offset: -1,
total_uncompressed_bytes: 0,
entry_count: 0,
}
}
pub fn append(&mut self, key: &[u8], value: &[u8]) -> Result<()> {
if self.entry_count > 0 && key < self.last_key_content.as_slice() {
return Err(HFileError::InvalidFormat(format!(
"HFile keys must be appended in ascending order: '{}' after '{}'",
String::from_utf8_lossy(key),
String::from_utf8_lossy(&self.last_key_content)
)));
}
if self.first_key_of_block.is_none() {
self.first_key_of_block = Some(structured_key(key)?);
}
let key_len = i32::try_from(key.len() + STRUCTURED_KEY_OVERHEAD)
.map_err(|_| HFileError::InvalidFormat("HFile key is too large".to_string()))?;
let value_len = i32::try_from(value.len())
.map_err(|_| HFileError::InvalidFormat("HFile value is too large".to_string()))?;
self.data.reserve(key.len() + value.len() + 21);
self.data.extend_from_slice(&key_len.to_be_bytes());
self.data.extend_from_slice(&value_len.to_be_bytes());
push_structured_key(&mut self.data, key)?;
self.data.extend_from_slice(value);
self.data.push(0);
self.last_key_content.clear();
self.last_key_content.extend_from_slice(key);
self.entry_count += 1;
if self.data.len() >= self.block_size {
self.flush_data_block()?;
}
Ok(())
}
pub fn entry_count(&self) -> u64 {
self.entry_count
}
fn flush_data_block(&mut self) -> Result<()> {
let Some(first_key) = self.first_key_of_block.take() else {
return Ok(());
};
let offset = self.output.len() as u64;
let block = write_block(
HFileBlockType::Data,
&self.data,
self.last_data_block_offset,
)?;
let on_disk_size = u32::try_from(block.len())
.map_err(|_| HFileError::InvalidFormat("HFile block is too large".to_string()))?;
self.output.extend_from_slice(&block);
self.total_uncompressed_bytes += self.data.len() as u64;
self.index.push((offset, on_disk_size, first_key));
self.last_data_block_offset = offset as i64;
self.data.clear();
Ok(())
}
pub fn finish(mut self, mut file_info: BTreeMap<String, Vec<u8>>) -> Result<Vec<u8>> {
self.flush_data_block()?;
let mut output = std::mem::take(&mut self.output);
if self.index.is_empty() {
output = write_block(HFileBlockType::Data, &[], -1)?;
}
let last_key = if self.entry_count > 0 {
structured_key(&self.last_key_content)?
} else {
Vec::new()
};
let mut root_index = Vec::new();
if self.index.is_empty() {
root_index.extend_from_slice(&0i64.to_be_bytes());
root_index.extend_from_slice(
&u32::try_from(output.len())
.map_err(|_| HFileError::InvalidFormat("HFile block is too large".to_string()))?
.to_be_bytes(),
);
write_hadoop_vlong(&mut root_index, 0);
}
for (offset, size, first_key) in &self.index {
root_index.extend_from_slice(&(*offset as i64).to_be_bytes());
root_index.extend_from_slice(&size.to_be_bytes());
write_hadoop_vlong(&mut root_index, first_key.len() as u64);
root_index.extend_from_slice(first_key);
}
let root_index_offset = output.len() as u64;
output.extend_from_slice(&write_block(
HFileBlockType::RootIndex,
&root_index,
self.last_data_block_offset.max(0),
)?);
let meta_index_offset = output.len() as u64;
output.extend_from_slice(&write_block(
HFileBlockType::RootIndex,
&[],
root_index_offset as i64,
)?);
file_info.insert("hfile.LASTKEY".to_string(), last_key);
file_info.insert("KEY_VALUE_VERSION".to_string(), 1i32.to_be_bytes().to_vec());
file_info.insert(
"MAX_MEMSTORE_TS_KEY".to_string(),
0i64.to_be_bytes().to_vec(),
);
let info = InfoProto {
map_entry: file_info
.into_iter()
.map(|(first, second)| BytesBytesPair {
first: first.into_bytes(),
second,
})
.collect(),
};
let mut info_bytes = b"PBUF".to_vec();
let encoded_info = info.encode_to_vec();
write_protobuf_varint(&mut info_bytes, encoded_info.len() as u64);
info_bytes.extend_from_slice(&encoded_info);
let file_info_offset = output.len() as u64;
output.extend_from_slice(&write_block(
HFileBlockType::FileInfo,
&info_bytes,
meta_index_offset as i64,
)?);
let trailer = TrailerProto {
file_info_offset: Some(file_info_offset),
load_on_open_data_offset: Some(root_index_offset),
uncompressed_data_index_size: Some(root_index.len() as u64),
total_uncompressed_bytes: Some(self.total_uncompressed_bytes),
data_index_count: Some(self.index.len() as u32),
meta_index_count: Some(0),
entry_count: Some(self.entry_count),
num_data_index_levels: Some(1),
first_data_block_offset: Some(0),
last_data_block_offset: Some(self.last_data_block_offset.max(0) as u64),
comparator_class_name: Some(
"org.apache.hudi.io.storage.HoodieHBaseKVComparator".to_string(),
),
compression_codec: Some(2),
encryption_key: None,
};
let encoded_trailer = trailer.encode_to_vec();
let mut trailer_bytes = vec![0; TRAILER_SIZE];
trailer_bytes[..8].copy_from_slice(HFileBlockType::Trailer.magic());
let mut length_delimited = Vec::new();
write_protobuf_varint(&mut length_delimited, encoded_trailer.len() as u64);
length_delimited.extend_from_slice(&encoded_trailer);
let trailer_end = 8 + length_delimited.len();
trailer_bytes[8..trailer_end].copy_from_slice(&length_delimited);
trailer_bytes[TRAILER_SIZE - 4..].copy_from_slice(&[0, 0, 0, 3]);
output.extend_from_slice(&trailer_bytes);
Ok(output)
}
}
const STRUCTURED_KEY_OVERHEAD: usize = 2 + 1 + 8 + 1;
fn structured_key(content: &[u8]) -> crate::hfile::error::Result<Vec<u8>> {
let mut key = Vec::with_capacity(content.len() + STRUCTURED_KEY_OVERHEAD);
push_structured_key(&mut key, content)?;
Ok(key)
}
fn push_structured_key(out: &mut Vec<u8>, content: &[u8]) -> crate::hfile::error::Result<()> {
let len = u16::try_from(content.len()).map_err(|_| {
crate::hfile::error::HFileError::InvalidFormat(format!(
"HFile row key exceeds 64KiB: {} bytes",
content.len()
))
})?;
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(content);
out.push(0);
out.extend_from_slice(&i64::MAX.to_be_bytes());
out.push(4);
Ok(())
}
fn write_block(block_type: HFileBlockType, data: &[u8], previous_offset: i64) -> Result<Vec<u8>> {
let checksum_bytes = (BLOCK_HEADER_SIZE + data.len()).div_ceil(BYTES_PER_CHECKSUM) * 4;
let on_disk_size = data
.len()
.checked_add(checksum_bytes)
.ok_or_else(|| HFileError::InvalidFormat("HFile block size overflow".to_string()))?;
let mut block = Vec::with_capacity(BLOCK_HEADER_SIZE + on_disk_size);
block.extend_from_slice(block_type.magic());
block.extend_from_slice(
&i32::try_from(on_disk_size)
.map_err(|_| HFileError::InvalidFormat("HFile block is too large".to_string()))?
.to_be_bytes(),
);
block.extend_from_slice(
&i32::try_from(data.len())
.map_err(|_| HFileError::InvalidFormat("HFile block is too large".to_string()))?
.to_be_bytes(),
);
block.extend_from_slice(&previous_offset.to_be_bytes());
block.push(CHECKSUM_TYPE_CRC32);
block.extend_from_slice(&(BYTES_PER_CHECKSUM as i32).to_be_bytes());
block.extend_from_slice(
&i32::try_from(BLOCK_HEADER_SIZE + data.len())
.map_err(|_| HFileError::InvalidFormat("HFile block is too large".to_string()))?
.to_be_bytes(),
);
block.extend_from_slice(data);
block.resize(BLOCK_HEADER_SIZE + on_disk_size, 0);
Ok(block)
}
fn write_hadoop_vlong(output: &mut Vec<u8>, value: u64) {
if value <= 127 {
output.push(value as u8);
return;
}
let bytes = value.to_be_bytes();
let first = bytes.iter().position(|byte| *byte != 0).unwrap_or(7);
let significant = &bytes[first..];
output.push((-(112 + significant.len() as i8)) as u8);
output.extend_from_slice(significant);
}
fn write_protobuf_varint(output: &mut Vec<u8>, mut value: u64) {
while value >= 0x80 {
output.push((value as u8 & 0x7f) | 0x80);
value >>= 7;
}
output.push(value as u8);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hfile::HFileReader;
#[test]
fn test_builder_writes_multiple_data_blocks() {
let mut builder = HFileBuilder::new(64);
let keys: Vec<String> = (0..50).map(|i| format!("key{i:03}")).collect();
for key in &keys {
builder
.append(key.as_bytes(), format!("value-{key}").as_bytes())
.unwrap();
}
let bytes = builder
.finish(BTreeMap::from([("schema".to_string(), b"s".to_vec())]))
.unwrap();
let trailer = crate::hfile::trailer::HFileTrailer::read(&bytes).unwrap();
assert!(trailer.data_index_count > 1, "{}", trailer.data_index_count);
assert_eq!(trailer.entry_count, 50);
let mut reader = HFileReader::new(bytes).unwrap();
let records = reader.collect_records().unwrap();
assert_eq!(
records
.iter()
.map(|r| r.key_as_str().unwrap().to_string())
.collect::<Vec<_>>(),
keys
);
let found = reader.lookup_records(&["key037"]).unwrap();
assert_eq!(found[0].1.as_ref().unwrap().value(), b"value-key037");
}
#[test]
fn test_builder_rejects_descending_keys() {
let mut builder = HFileBuilder::new(1024);
builder.append(b"b", b"1").unwrap();
assert!(builder.append(b"a", b"2").is_err());
}
#[test]
fn test_write_roundtrip_records() {
let bytes = HFileWriter::write(
&[
("charlie".to_string(), b"3".to_vec()),
("alpha".to_string(), b"1".to_vec()),
("bravo".to_string(), b"2".to_vec()),
],
BTreeMap::new(),
)
.expect("write hfile");
let mut reader = HFileReader::new(bytes.clone()).expect("read hfile");
let records = reader.collect_records().expect("collect records");
assert_eq!(records.len(), 3);
assert_eq!(records[0].key_as_str(), Some("alpha"));
assert_eq!(records[2].key_as_str(), Some("charlie"));
assert_eq!(
crate::hfile::trailer::HFileTrailer::read(&bytes)
.expect("read trailer")
.comparator_class_name,
"org.apache.hudi.io.storage.HoodieHBaseKVComparator"
);
}
}