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 e8ff14a1 perf(core): copy an HFile record rather than its whole block
per record (#697)
e8ff14a1 is described below
commit e8ff14a15e6a7e035f6f86f860a6cff5e7460747
Author: Lin Liu <[email protected]>
AuthorDate: Tue Sep 1 18:40:03 2026 -0700
perf(core): copy an HFile record rather than its whole block per record
(#697)
---
crates/core/src/hfile/key.rs | 121 +++++++++++++++++++++++++++++++++++++++----
1 file changed, 110 insertions(+), 11 deletions(-)
diff --git a/crates/core/src/hfile/key.rs b/crates/core/src/hfile/key.rs
index c5580e8f..3c53da75 100644
--- a/crates/core/src/hfile/key.rs
+++ b/crates/core/src/hfile/key.rs
@@ -37,9 +37,9 @@ pub const KEY_VALUE_HEADER_SIZE: usize = SIZEOF_INT32 * 2;
/// For comparison and hashing, only the key content is used.
#[derive(Debug, Clone)]
pub struct Key {
- /// Raw key bytes including the length prefix
+ /// This key's own bytes, including the length prefix
bytes: Vec<u8>,
- /// Offset to the start of the key within bytes
+ /// Offset to the start of the key within `bytes`; zero when parsed from a
block
offset: usize,
/// Total length of the key part (including length prefix and other info)
length: usize,
@@ -47,10 +47,18 @@ pub struct Key {
impl Key {
/// Create a new Key from bytes at the given offset with the specified
length.
+ ///
+ /// Copies the key's own bytes, not the buffer it came from. `bytes` is a
whole
+ /// data block, so copying it here cost the block once per key: parsing a
block
+ /// of N keys copied it N times, which is quadratic in the block's record
count.
pub fn new(bytes: &[u8], offset: usize, length: usize) -> Self {
+ let end = offset.saturating_add(length).min(bytes.len());
+ let start = offset.min(end);
Self {
- bytes: bytes.to_vec(),
- offset,
+ bytes: bytes[start..end].to_vec(),
+ // Zero because the bytes above start at the key, so every
accessor's
+ // arithmetic stays as it was when this held the whole block.
+ offset: 0,
length,
}
}
@@ -65,8 +73,12 @@ impl Key {
}
}
- /// Returns the offset to the key content (after length prefix).
- pub fn content_offset(&self) -> usize {
+ /// 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
}
@@ -80,6 +92,12 @@ impl Key {
}
/// Returns the key content as a byte slice.
+ ///
+ /// The bound below is against this key's own bytes. For a key parsed out
of a
+ /// block that is `length` bytes, so a corrupt inner prefix claiming more
content
+ /// than the key holds yields empty rather than reading on into the value
that
+ /// 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();
@@ -99,7 +117,7 @@ impl Key {
self.length
}
- /// Returns the raw bytes.
+ /// Returns this key's own bytes, prefix included.
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
@@ -206,9 +224,9 @@ impl std::fmt::Display for Utf8Key {
/// - 1 byte: MVCC timestamp version (always 0 for Hudi)
#[derive(Debug, Clone)]
pub struct KeyValue {
- /// The backing byte array containing the entire key-value record
+ /// This record's own bytes: header, key and value
bytes: Vec<u8>,
- /// Offset to the start of this record in bytes
+ /// Offset to the start of this record within `bytes`; zero when parsed
offset: usize,
/// The parsed key
key: Key,
@@ -238,9 +256,21 @@ impl KeyValue {
let key_offset = offset + KEY_VALUE_HEADER_SIZE;
let key = Key::new(bytes, key_offset, key_length);
+ // This record's own bytes, not the block's. Copying the block here
cost it
+ // once per record, so a block of N records copied itself N times on
top of
+ // the N copies `Key::new` made.
+ let record_end = offset
+ .saturating_add(KEY_VALUE_HEADER_SIZE)
+ .saturating_add(key_length)
+ .saturating_add(value_length)
+ .min(bytes.len());
+ let record_start = offset.min(record_end);
+
Self {
- bytes: bytes.to_vec(),
- offset,
+ bytes: bytes[record_start..record_end].to_vec(),
+ // Zero for the same reason as in `Key::new`: the bytes now start
at the
+ // record, so `value()`'s arithmetic is unchanged.
+ offset: 0,
key,
key_length,
value_length,
@@ -465,4 +495,73 @@ mod tests {
assert_eq!(compare_keys(&key, &lookup2), Ordering::Less);
assert_eq!(compare_keys(&key, &lookup3), Ordering::Greater);
}
+
+ /// A record parsed at a nonzero offset reads its own key and value, and
holds
+ /// only its own bytes.
+ ///
+ /// Every other test here parses at offset 0, where a record and the
buffer it
+ /// came from are nearly the same thing, so none of them would notice a
parse
+ /// that ignored the offset or sliced from the start. The narrowing is
what this
+ /// pins: the second record's buffer must span that record, not the block,
since
+ /// holding the block is what made parsing a block quadratic in its record
count.
+ #[test]
+ fn a_record_at_a_nonzero_offset_reads_itself_and_holds_only_itself() {
+ // Two records in one buffer, laid out as an HFile data block does it:
+ // 4-byte key length, 4-byte value length, key, value, 1-byte MVCC.
+ fn record(key_content: &[u8], value: &[u8]) -> Vec<u8> {
+ let mut key = Vec::new();
+ key.extend_from_slice(&(key_content.len() as i16).to_be_bytes());
+ key.extend_from_slice(key_content);
+ let mut out = Vec::new();
+ out.extend_from_slice(&(key.len() as i32).to_be_bytes());
+ out.extend_from_slice(&(value.len() as i32).to_be_bytes());
+ out.extend_from_slice(&key);
+ out.extend_from_slice(value);
+ out.push(0); // MVCC timestamp version
+ out
+ }
+
+ let first = record(b"aaa", b"value-of-first");
+ let second = record(b"bbbb", b"second-value");
+ let mut block = first.clone();
+ block.extend_from_slice(&second);
+
+ let kv0 = KeyValue::parse(&block, 0);
+ assert_eq!(kv0.key().content(), b"aaa");
+ assert_eq!(kv0.value(), b"value-of-first");
+ assert_eq!(kv0.record_size(), first.len());
+
+ // The offset the block iterator would advance to.
+ let kv1 = KeyValue::parse(&block, kv0.record_size());
+ assert_eq!(
+ kv1.key().content(),
+ b"bbbb",
+ "the second record's key must be read from its own offset"
+ );
+ assert_eq!(
+ kv1.value(),
+ b"second-value",
+ "the second record's value must be read from its own offset"
+ );
+ assert_eq!(kv1.record_size(), second.len());
+
+ // Narrow, not the whole block: this is the property whose absence
made a
+ // block of N records copy itself N times.
+ assert_eq!(
+ kv1.key().bytes().len(),
+ kv1.key_length(),
+ "a parsed key must hold exactly its own bytes, not the block's"
+ );
+ assert!(
+ kv1.bytes.len() < block.len(),
+ "a parsed record must hold less than the whole block, got {} of
{}",
+ kv1.bytes.len(),
+ block.len()
+ );
+ assert_eq!(
+ kv1.bytes.len(),
+ KEY_VALUE_HEADER_SIZE + kv1.key_length() + kv1.value_length(),
+ "a parsed record must hold exactly its header, key and value"
+ );
+ }
}