This is an automated email from the ASF dual-hosted git repository.
yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 9f5d1c08 perf(core): match record index keys in place before building
records (#906)
9f5d1c08 is described below
commit 9f5d1c081011962156e33911ffc359acfb69aa41
Author: Y Ethan Guo <[email protected]>
AuthorDate: Fri Oct 30 01:42:27 2026 -0700
perf(core): match record index keys in place before building records (#906)
`DataBlock::entries` walks an HFile data block's entries as borrowed key
and value slices, and fails on an entry that does not fit the block rather than
ending the walk early, since a lookup that stopped quietly would report its
keys as absent. `HFileReader::records_in_block` now takes a predicate and
builds records only for the keys it admits, and the record index log-block scan
passes its key matcher, so a lookup of a few thousand keys among millions of
entries pays key comparisons [...]
On a 10M-row table, a 10,000-key upsert spent about 90% of its time in this
decode before the change.
---
crates/core/src/file_group/log_file/content.rs | 17 ++--
crates/core/src/hfile/block.rs | 114 ++++++++++++++++++++++++-
crates/core/src/hfile/key.rs | 36 ++++----
crates/core/src/hfile/reader.rs | 80 ++++++++++++++---
crates/core/src/index/record.rs | 2 +-
5 files changed, 208 insertions(+), 41 deletions(-)
diff --git a/crates/core/src/file_group/log_file/content.rs
b/crates/core/src/file_group/log_file/content.rs
index 0ed1a49b..64d170ac 100644
--- a/crates/core/src/file_group/log_file/content.rs
+++ b/crates/core/src/file_group/log_file/content.rs
@@ -674,16 +674,13 @@ impl Decoder {
if let Some(predicate) = self.key_predicate.as_ref() {
let matcher = predicate.matcher();
for entry in hfile_reader.blocks_for_predicate(predicate) {
- for record in hfile_reader
- .records_in_block(&entry)
- .map_err(|e| CoreError::HFile(e.to_string()))?
- {
- // A block is the smallest readable unit, so a selected
block
- // holds keys nobody asked for.
- if record.key_as_str().is_some_and(|key|
matcher.admits(key)) {
- records.push(record);
- }
- }
+ // A block is the smallest readable unit, so a selected block
+ // holds keys nobody asked for; only the admitted ones are
built.
+ records.extend(
+ hfile_reader
+ .records_in_block(&entry, |key| matcher.admits(key))
+ .map_err(|e| CoreError::HFile(e.to_string()))?,
+ );
}
return Ok(records);
}
diff --git a/crates/core/src/hfile/block.rs b/crates/core/src/hfile/block.rs
index 67849305..60437569 100644
--- a/crates/core/src/hfile/block.rs
+++ b/crates/core/src/hfile/block.rs
@@ -21,7 +21,7 @@
use crate::hfile::block_type::{HFileBlockType, MAGIC_LENGTH};
use crate::hfile::compression::CompressionCodec;
use crate::hfile::error::{HFileError, Result};
-use crate::hfile::key::{KEY_VALUE_HEADER_SIZE, Key, KeyValue};
+use crate::hfile::key::{KEY_VALUE_HEADER_SIZE, Key, KeyValue, key_content};
/// Size constants for block header
const SIZEOF_INT32: usize = 4;
@@ -211,6 +211,19 @@ impl DataBlock {
offset < self.content_end
}
+ /// The record key content and value of every entry, as slices of the
+ /// block.
+ ///
+ /// [`Self::iter`] copies each entry out of the block; a caller that only
+ /// wants to find a few keys among thousands of entries can look at the
+ /// keys in place and copy the entries it keeps.
+ pub fn entries(&self) -> DataBlockEntries<'_> {
+ DataBlockEntries {
+ block: self,
+ offset: 0,
+ }
+ }
+
/// Iterate over all key-value pairs in the block.
#[allow(dead_code)]
pub fn iter(&self) -> DataBlockIterator<'_> {
@@ -252,6 +265,52 @@ impl<'a> Iterator for DataBlockIterator<'a> {
}
}
+/// Borrowing walk over a data block's entries: see [`DataBlock::entries`].
+pub struct DataBlockEntries<'a> {
+ block: &'a DataBlock,
+ offset: usize,
+}
+
+impl<'a> Iterator for DataBlockEntries<'a> {
+ /// `(key content, value)`, the bytes `KeyValue::key().content()` and
+ /// `KeyValue::value()` return for the entry, or an error for an entry
+ /// that does not fit the block. The error ends the walk: a lookup that
+ /// quietly stopped at a corrupt entry would report its keys as absent.
+ type Item = Result<(&'a [u8], &'a [u8])>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let data = &self.block.data[..self.block.content_end];
+ let offset = self.offset;
+ if offset + KEY_VALUE_HEADER_SIZE > data.len() {
+ return None;
+ }
+ let truncated = || {
+ HFileError::InvalidFormat(format!(
+ "HFile data block entry at offset {offset} runs past the block"
+ ))
+ };
+ let key_length = i32::from_be_bytes(data[offset..offset +
4].try_into().ok()?);
+ let value_length = i32::from_be_bytes(data[offset + 4..offset +
8].try_into().ok()?);
+ let (Ok(key_length), Ok(value_length)) =
+ (usize::try_from(key_length), usize::try_from(value_length))
+ else {
+ self.offset = data.len();
+ return Some(Err(truncated()));
+ };
+ let key_start = offset + KEY_VALUE_HEADER_SIZE;
+ let value_start = key_start.saturating_add(key_length);
+ let end = value_start.saturating_add(value_length);
+ // One MVCC timestamp byte follows the value (`KeyValue::record_size`).
+ if end.saturating_add(1) > data.len() {
+ self.offset = data.len();
+ return Some(Err(truncated()));
+ }
+ self.offset = end + 1;
+ let content = key_content(&data[key_start..value_start]);
+ Some(Ok((content, &data[value_start..end])))
+ }
+}
+
/// Read a Hadoop VLong encoded integer from bytes.
/// This is the encoding used in HFile root/leaf index blocks.
/// Returns (value, bytes_consumed).
@@ -301,6 +360,59 @@ pub fn var_long_size_on_disk(bytes: &[u8], offset: usize)
-> usize {
#[cfg(test)]
mod tests {
+ /// One entry as the writer lays it out: lengths, structured key, value,
+ /// MVCC byte.
+ fn entry(content: &[u8], value: &[u8]) -> Vec<u8> {
+ let mut key = (content.len() as u16).to_be_bytes().to_vec();
+ key.extend_from_slice(content);
+ key.push(0);
+ key.extend_from_slice(&i64::MAX.to_be_bytes());
+ key.push(4);
+ let mut out = (key.len() as i32).to_be_bytes().to_vec();
+ out.extend_from_slice(&(value.len() as i32).to_be_bytes());
+ out.extend_from_slice(&key);
+ out.extend_from_slice(value);
+ out.push(0);
+ out
+ }
+
+ fn data_block(bytes: Vec<u8>) -> super::DataBlock {
+ let content_end = bytes.len();
+ super::DataBlock {
+ data: bytes,
+ content_end,
+ }
+ }
+
+ /// The borrowing walk sees what the copying iterator sees, and a
+ /// truncated entry is an error rather than a silent end.
+ #[test]
+ fn entries_match_key_values_and_reject_a_truncated_entry() {
+ let mut bytes = entry(b"k1", b"v1");
+ bytes.extend(entry(b"k2", b"value two"));
+ let block = data_block(bytes.clone());
+ let walked: Vec<(Vec<u8>, Vec<u8>)> = block
+ .entries()
+ .map(|e| e.map(|(k, v)| (k.to_vec(), v.to_vec())).unwrap())
+ .collect();
+ let copied: Vec<(Vec<u8>, Vec<u8>)> = block
+ .iter()
+ .map(|kv| (kv.key().content().to_vec(), kv.value().to_vec()))
+ .collect();
+ assert_eq!(walked, copied);
+ assert_eq!(walked[1], (b"k2".to_vec(), b"value two".to_vec()));
+
+ // Drop the tail of the second entry.
+ let truncated = data_block(bytes[..bytes.len() - 4].to_vec());
+ let results: Vec<_> = truncated.entries().collect();
+ assert_eq!(results.len(), 2);
+ assert!(results[0].is_ok());
+ assert!(matches!(
+ results[1],
+ Err(super::HFileError::InvalidFormat(_))
+ ));
+ }
+
use super::*;
#[test]
diff --git a/crates/core/src/hfile/key.rs b/crates/core/src/hfile/key.rs
index 4733ebca..822cee0f 100644
--- a/crates/core/src/hfile/key.rs
+++ b/crates/core/src/hfile/key.rs
@@ -91,15 +91,6 @@ impl Key {
Some(Self::from_bytes(bytes))
}
- /// Returns the offset to the key content (after the length prefix).
- ///
- /// Private: this is an offset into *this key's* bytes, so it is only
meaningful
- /// with [`Self::bytes`]. It used to be an offset into the enclosing
block, and a
- /// caller pairing it with a block buffer would now index the wrong place.
- fn content_offset(&self) -> usize {
- self.offset + SIZEOF_INT16
- }
-
/// Returns the length of the key content.
pub fn content_length(&self) -> usize {
if self.bytes.len() < self.offset + SIZEOF_INT16 {
@@ -117,12 +108,7 @@ impl Key {
/// follows it. A well-formed HFile cannot reach that: `key_length` is
/// `2 + row + 1 + family + qualifier + 9`, always at least `2 + content`.
pub fn content(&self) -> &[u8] {
- let start = self.content_offset();
- let len = self.content_length();
- if start + len > self.bytes.len() {
- return &[];
- }
- &self.bytes[start..start + len]
+ key_content(&self.bytes[self.offset..])
}
/// Returns the key content as a UTF-8 string.
@@ -141,6 +127,20 @@ impl Key {
}
}
+/// The content of a key: a 2-byte signed length, then that many bytes.
+///
+/// Empty when the length is missing, negative, or longer than the key, rather
+/// than reading on into the value that follows the key in a block.
+pub(crate) fn key_content(key: &[u8]) -> &[u8] {
+ let Some((length, rest)) = key.split_first_chunk::<SIZEOF_INT16>() else {
+ return &[];
+ };
+ usize::try_from(i16::from_be_bytes(*length))
+ .ok()
+ .and_then(|length| rest.get(..length))
+ .unwrap_or(&[])
+}
+
impl PartialEq for Key {
fn eq(&self, other: &Self) -> bool {
self.content() == other.content()
@@ -401,6 +401,12 @@ mod tests {
assert_eq!(key.content_length(), 0);
}
+ #[test]
+ fn test_key_content_rejects_a_negative_length() {
+ let key = Key::from_bytes(vec![0xFF, 0xFF, b'a', b'b']);
+ assert_eq!(key.content(), b"");
+ }
+
#[test]
fn test_key_content_out_of_bounds() {
// Key claims content length of 10 but only has 3 bytes
diff --git a/crates/core/src/hfile/reader.rs b/crates/core/src/hfile/reader.rs
index adbf4a3a..c097cc9d 100644
--- a/crates/core/src/hfile/reader.rs
+++ b/crates/core/src/hfile/reader.rs
@@ -413,14 +413,39 @@ impl HFileReader {
}
}
- /// The records of one data block, from a resident source.
+ /// The records of one data block whose key `admits`, from a resident
source.
///
/// The synchronous counterpart to [`Self::read_records_batched`], which
needs a
/// ranged source because it fetches. An HFile arriving as a log block's
content
/// is already in memory, so a caller that has narrowed the blocks with
/// [`Self::blocks_for_predicate`] wants to parse just those rather than
walk the
- /// file — the saving is parsing and decompression, not I/O.
- pub fn records_in_block(&self, entry: &BlockIndexEntry) ->
Result<Vec<HFileRecord>> {
+ /// file — the saving is parsing and decompression, not I/O. Keys are
compared
+ /// in place and only the admitted entries are copied out, so a lookup of
a few
+ /// keys in a block of thousands costs the key comparisons rather than a
record
+ /// allocation per entry.
+ pub fn records_in_block(
+ &self,
+ entry: &BlockIndexEntry,
+ admits: impl Fn(&str) -> bool,
+ ) -> Result<Vec<HFileRecord>> {
+ let data_block = self.data_block(entry)?;
+ // Records carry the file's own writer schema so a decoder can honor
the
+ // shape this file was written with rather than a vendored one.
+ let schema = self.get_avro_schema_arc()?;
+ let mut records = Vec::new();
+ for entry in data_block.entries() {
+ let (key, value) = entry?;
+ if std::str::from_utf8(key).is_ok_and(&admits) {
+ records.push(
+ HFileRecord::new(key.to_vec(),
value.to_vec()).with_avro_schema(schema.clone()),
+ );
+ }
+ }
+ Ok(records)
+ }
+
+ /// The data block an index entry points at.
+ fn data_block(&self, entry: &BlockIndexEntry) -> Result<DataBlock> {
let block = self.read_block_at(entry.offset as usize, entry.size as
usize)?;
if block.block_type() != HFileBlockType::Data {
return Err(HFileError::UnexpectedBlockType {
@@ -428,17 +453,7 @@ impl HFileReader {
actual: block.block_type().to_string(),
});
}
- let data_block = DataBlock::from_block(block);
- // Records carry the file's own writer schema so a decoder can honor
the
- // shape this file was written with rather than a vendored one.
- let schema = self.get_avro_schema_arc()?;
- // The shared helper, not a second copy of it: two places building an
- // `HFileRecord` from a `KeyValue` is how the predicate path and the
scan path
- // would drift on what a record key is.
- Ok(data_block
- .iter()
- .map(|kv| Self::key_value_to_record(&kv, schema.clone()))
- .collect())
+ Ok(DataBlock::from_block(block))
}
/// The data blocks a key predicate can be satisfied from.
@@ -1993,6 +2008,43 @@ mod tests {
assert_eq!(record.key_as_str(), Some("hudi-key-000000000"));
}
+ /// The in-place key walk sees the same entries as the copying iterator,
+ /// and an admitting predicate keeps exactly the entries it admits.
+ #[test]
+ fn test_records_in_block_matches_the_copying_iterator() {
+ let bytes =
read_test_hfile("hudi_1_0_hbase_2_4_9_16KB_NONE_5000.hfile");
+ let reader = HFileReader::new(bytes).expect("Failed to create reader");
+ let entry = reader.blocks_for_keys(&["hudi-key-000000100"])[0].clone();
+ let pairs = |records: Vec<HFileRecord>| -> Vec<(Vec<u8>, Vec<u8>)> {
+ records
+ .iter()
+ .map(|r| (r.key().to_vec(), r.value().to_vec()))
+ .collect()
+ };
+ let copied: Vec<(Vec<u8>, Vec<u8>)> = reader
+ .data_block(&entry)
+ .unwrap()
+ .iter()
+ .map(|kv| (kv.key().content().to_vec(), kv.value().to_vec()))
+ .collect();
+ assert!(copied.len() > 1);
+ assert_eq!(
+ pairs(reader.records_in_block(&entry, |_| true).unwrap()),
+ copied
+ );
+
+ let wanted = |key: &str| key.ends_with("100") || key.ends_with("101");
+ let expected: Vec<(Vec<u8>, Vec<u8>)> = copied
+ .into_iter()
+ .filter(|(k, _)| wanted(std::str::from_utf8(k).unwrap()))
+ .collect();
+ assert_eq!(expected.len(), 2);
+ assert_eq!(
+ pairs(reader.records_in_block(&entry, wanted).unwrap()),
+ expected
+ );
+ }
+
#[test]
fn test_lookup_records() {
let bytes =
read_test_hfile("hudi_1_0_hbase_2_4_9_16KB_NONE_5000.hfile");
diff --git a/crates/core/src/index/record.rs b/crates/core/src/index/record.rs
index 2ebece78..808141d1 100644
--- a/crates/core/src/index/record.rs
+++ b/crates/core/src/index/record.rs
@@ -378,7 +378,7 @@ mod tests {
let mut firsts = Vec::new();
let mut lasts = Vec::new();
for e in &entries {
- let recs = reader.records_in_block(e).unwrap();
+ let recs = reader.records_in_block(e, |_| true).unwrap();
firsts.push(recs.first().unwrap().key_as_str().unwrap().to_string());
lasts.push(recs.last().unwrap().key_as_str().unwrap().to_string());
}