use crate::io::FileIO;
use crate::spec::{
batch_hash_codes, batch_to_serialized_bytes, DataField, IndexFileMeta, IndexManifest,
IndexManifestEntry, EMPTY_SERIALIZED_ROW,
};
use crate::table::bucket_assigner::{BatchAssignOutput, BucketAssigner, PartitionBucketKey};
use crate::table::SnapshotManager;
use crate::Result;
use arrow_array::RecordBatch;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
const HASH_INDEX: &str = "HASH";
struct HashIndexFile;
impl HashIndexFile {
async fn read(file_io: &FileIO, path: &str) -> Result<Vec<i32>> {
let input = file_io.new_input(path)?;
let content = input.read().await?;
debug_assert!(
content.len() % 4 == 0,
"hash index file size {} is not aligned to 4 bytes",
content.len()
);
let count = content.len() / 4;
let mut hashes = Vec::with_capacity(count);
for i in 0..count {
let offset = i * 4;
let bytes = [
content[offset],
content[offset + 1],
content[offset + 2],
content[offset + 3],
];
hashes.push(i32::from_be_bytes(bytes));
}
Ok(hashes)
}
async fn write(file_io: &FileIO, dir: &str, hashes: &[i32]) -> Result<IndexFileMeta> {
let file_name = format!("index-{}-0", Uuid::new_v4());
let path = format!("{dir}/{file_name}");
let mut buf = Vec::with_capacity(hashes.len() * 4);
for &h in hashes {
buf.extend_from_slice(&h.to_be_bytes());
}
let file_size: i32 = buf
.len()
.try_into()
.expect("hash index file size exceeds i32::MAX");
let output = file_io.new_output(&path)?;
output.write(bytes::Bytes::from(buf)).await?;
Ok(IndexFileMeta {
index_type: HASH_INDEX.to_string(),
file_name,
file_size,
row_count: hashes
.len()
.try_into()
.expect("hash index row count exceeds i32::MAX"),
deletion_vectors_ranges: None,
global_index_meta: None,
})
}
}
pub(crate) struct DynamicBucketIndexMaintainer {
hashes: HashSet<i32>,
modified: bool,
}
impl DynamicBucketIndexMaintainer {
pub fn new(restored_hashes: Vec<i32>) -> Self {
let hashes: HashSet<i32> = restored_hashes.into_iter().collect();
Self {
hashes,
modified: false,
}
}
pub fn notify_new_record(&mut self, key_hash: i32) {
if self.hashes.insert(key_hash) {
self.modified = true;
}
}
pub async fn prepare_commit(
&mut self,
file_io: &FileIO,
index_dir: &str,
) -> Result<Vec<IndexFileMeta>> {
if !self.modified {
return Ok(Vec::new());
}
let hashes: Vec<i32> = self.hashes.iter().copied().collect();
let meta = HashIndexFile::write(file_io, index_dir, &hashes).await?;
self.modified = false;
Ok(vec![meta])
}
}
struct PartitionIndex {
hash_to_bucket: HashMap<i32, i32>,
non_full_buckets: HashMap<i32, i64>,
all_buckets: HashSet<i32>,
next_bucket_id: i32,
target_bucket_row_number: i64,
bucket_maintainers: HashMap<i32, DynamicBucketIndexMaintainer>,
}
impl PartitionIndex {
fn empty(target_bucket_row_number: i64) -> Self {
Self {
hash_to_bucket: HashMap::new(),
non_full_buckets: HashMap::new(),
all_buckets: HashSet::new(),
next_bucket_id: 0,
target_bucket_row_number,
bucket_maintainers: HashMap::new(),
}
}
async fn load(
file_io: &FileIO,
index_dir: &str,
entries: &[IndexManifestEntry],
target_bucket_row_number: i64,
) -> Result<Self> {
let mut hash_to_bucket = HashMap::new();
let mut bucket_row_counts: HashMap<i32, i64> = HashMap::new();
let mut bucket_hashes: HashMap<i32, Vec<i32>> = HashMap::new();
for entry in entries {
if entry.index_file.index_type != HASH_INDEX {
continue;
}
let bucket = entry.bucket;
let path = format!("{index_dir}/{}", entry.index_file.file_name);
let hashes = HashIndexFile::read(file_io, &path).await?;
let count = hashes.len() as i64;
for &h in &hashes {
hash_to_bucket.insert(h, bucket);
}
*bucket_row_counts.entry(bucket).or_insert(0) += count;
bucket_hashes.entry(bucket).or_default().extend(hashes);
}
let all_buckets: HashSet<i32> = bucket_row_counts.keys().copied().collect();
let non_full_buckets: HashMap<i32, i64> = bucket_row_counts
.into_iter()
.filter(|(_, count)| *count < target_bucket_row_number)
.collect();
let bucket_maintainers: HashMap<i32, DynamicBucketIndexMaintainer> = bucket_hashes
.into_iter()
.map(|(bucket, hashes)| (bucket, DynamicBucketIndexMaintainer::new(hashes)))
.collect();
let next_bucket_id = all_buckets.iter().copied().max().map_or(0, |m| m + 1);
Ok(Self {
hash_to_bucket,
non_full_buckets,
all_buckets,
next_bucket_id,
target_bucket_row_number,
bucket_maintainers,
})
}
fn assign(&mut self, hash: i32) -> i32 {
if let Some(&bucket) = self.hash_to_bucket.get(&hash) {
return bucket;
}
let mut full_buckets = Vec::new();
let mut assigned_bucket = None;
for (&bucket, count) in &mut self.non_full_buckets {
if *count < self.target_bucket_row_number {
*count += 1;
self.hash_to_bucket.insert(hash, bucket);
assigned_bucket = Some(bucket);
break;
} else {
full_buckets.push(bucket);
}
}
for b in full_buckets {
self.non_full_buckets.remove(&b);
}
if let Some(bucket) = assigned_bucket {
self.bucket_maintainers
.entry(bucket)
.or_insert_with(|| DynamicBucketIndexMaintainer::new(vec![]))
.notify_new_record(hash);
return bucket;
}
let new_bucket = self.next_bucket_id;
self.next_bucket_id += 1;
self.all_buckets.insert(new_bucket);
self.non_full_buckets.insert(new_bucket, 1);
self.hash_to_bucket.insert(hash, new_bucket);
self.bucket_maintainers
.entry(new_bucket)
.or_insert_with(|| DynamicBucketIndexMaintainer::new(vec![]))
.notify_new_record(hash);
new_bucket
}
async fn prepare_commit(
&mut self,
file_io: &FileIO,
index_dir: &str,
) -> Result<Vec<(i32, Vec<IndexFileMeta>)>> {
let mut result = Vec::new();
let buckets: Vec<i32> = self.bucket_maintainers.keys().copied().collect();
for bucket in buckets {
if let Some(maintainer) = self.bucket_maintainers.get_mut(&bucket) {
let files = maintainer.prepare_commit(file_io, index_dir).await?;
if !files.is_empty() {
result.push((bucket, files));
}
}
}
Ok(result)
}
}
pub(crate) struct DynamicBucketAssigner {
partition_field_indices: Vec<usize>,
primary_key_indices: Vec<usize>,
fields: Vec<DataField>,
partition_indexes: HashMap<Vec<u8>, PartitionIndex>,
target_bucket_row_number: i64,
file_io: FileIO,
table_location: String,
cached_index_entries: Option<Vec<IndexManifestEntry>>,
is_overwrite: bool,
}
impl DynamicBucketAssigner {
pub fn new(
partition_field_indices: Vec<usize>,
primary_key_indices: Vec<usize>,
fields: Vec<DataField>,
target_bucket_row_number: i64,
file_io: FileIO,
table_location: String,
is_overwrite: bool,
) -> Self {
Self {
partition_field_indices,
primary_key_indices,
fields,
partition_indexes: HashMap::new(),
target_bucket_row_number,
file_io,
table_location,
cached_index_entries: None,
is_overwrite,
}
}
pub fn set_overwrite(&mut self, is_overwrite: bool) {
self.is_overwrite = is_overwrite;
}
async fn ensure_index_entries_loaded(&mut self) -> Result<()> {
if self.cached_index_entries.is_some() {
return Ok(());
}
if self.is_overwrite {
self.cached_index_entries = Some(Vec::new());
return Ok(());
}
let snapshot_manager =
SnapshotManager::new(self.file_io.clone(), self.table_location.clone());
let latest_snapshot = snapshot_manager.get_latest_snapshot().await?;
let entries = if let Some(snapshot) = latest_snapshot {
if let Some(index_manifest_name) = snapshot.index_manifest() {
let manifest_dir = snapshot_manager.manifest_dir();
let index_manifest_path = format!("{manifest_dir}/{index_manifest_name}");
IndexManifest::read(&self.file_io, &index_manifest_path).await?
} else {
Vec::new()
}
} else {
Vec::new()
};
self.cached_index_entries = Some(entries);
Ok(())
}
async fn load_partition_index(&self, partition_bytes: &[u8]) -> Result<PartitionIndex> {
let entries = self.cached_index_entries.as_deref().unwrap_or(&[]);
let partition_entries: Vec<_> = entries
.iter()
.filter(|e| e.partition == partition_bytes && e.index_file.index_type == HASH_INDEX)
.cloned()
.collect();
if !partition_entries.is_empty() {
let index_dir = format!("{}/index", self.table_location);
return PartitionIndex::load(
&self.file_io,
&index_dir,
&partition_entries,
self.target_bucket_row_number,
)
.await;
}
Ok(PartitionIndex::empty(self.target_bucket_row_number))
}
}
impl BucketAssigner for DynamicBucketAssigner {
async fn assign_batch(
&mut self,
batch: &RecordBatch,
_fields: &[DataField],
) -> Result<BatchAssignOutput> {
let partition_bytes_vec = if self.partition_field_indices.is_empty() {
vec![EMPTY_SERIALIZED_ROW.clone(); batch.num_rows()]
} else {
batch_to_serialized_bytes(batch, &self.partition_field_indices, &self.fields)?
};
let mut unseen = Vec::new();
let mut seen_set = HashSet::new();
for pb in &partition_bytes_vec {
if !self.partition_indexes.contains_key(pb) && seen_set.insert(pb.clone()) {
unseen.push(pb.clone());
}
}
if !unseen.is_empty() {
self.ensure_index_entries_loaded().await?;
}
for partition_bytes in unseen {
let index = self.load_partition_index(&partition_bytes).await?;
self.partition_indexes.insert(partition_bytes, index);
}
let hash_codes = batch_hash_codes(batch, &self.primary_key_indices, &self.fields)?;
let mut buckets = Vec::with_capacity(batch.num_rows());
for (row_idx, pb) in partition_bytes_vec.iter().enumerate() {
let partition_index = self.partition_indexes.get_mut(pb).unwrap();
buckets.push(partition_index.assign(hash_codes[row_idx]));
}
Ok(BatchAssignOutput {
partition_bytes: partition_bytes_vec,
buckets,
deletes: Vec::new(),
skips: Vec::new(),
})
}
async fn prepare_commit_index(
&mut self,
file_io: &FileIO,
index_dir: &str,
) -> Result<HashMap<PartitionBucketKey, Vec<IndexFileMeta>>> {
let mut result = HashMap::new();
let partition_keys: Vec<Vec<u8>> = self.partition_indexes.keys().cloned().collect();
for partition_bytes in partition_keys {
if let Some(partition_index) = self.partition_indexes.get_mut(&partition_bytes) {
let bucket_files = partition_index.prepare_commit(file_io, index_dir).await?;
for (bucket, idx_files) in bucket_files {
result.insert((partition_bytes.clone(), bucket), idx_files);
}
}
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_maintainer_write_on_modify() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = format!("file://{}", tmp.path().display());
let file_io = FileIO::from_url(&dir).unwrap().build().unwrap();
let mut m = DynamicBucketIndexMaintainer::new(vec![]);
let files = m.prepare_commit(&file_io, &dir).await.unwrap();
assert!(files.is_empty());
m.notify_new_record(1);
m.notify_new_record(2);
m.notify_new_record(1); let files = m.prepare_commit(&file_io, &dir).await.unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].index_type, HASH_INDEX);
assert_eq!(files[0].row_count, 2);
let files = m.prepare_commit(&file_io, &dir).await.unwrap();
assert!(files.is_empty());
}
#[tokio::test]
async fn test_maintainer_with_restored() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = format!("file://{}", tmp.path().display());
let file_io = FileIO::from_url(&dir).unwrap().build().unwrap();
let mut m = DynamicBucketIndexMaintainer::new(vec![10, 20]);
let files = m.prepare_commit(&file_io, &dir).await.unwrap();
assert!(files.is_empty());
m.notify_new_record(30);
let files = m.prepare_commit(&file_io, &dir).await.unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].row_count, 3);
}
#[test]
fn test_assign_new_keys() {
let mut index = PartitionIndex::empty(3);
assert_eq!(index.assign(100), 0);
assert_eq!(index.assign(200), 0);
assert_eq!(index.assign(300), 0);
assert_eq!(index.assign(400), 1);
}
#[test]
fn test_assign_existing_key() {
let mut index = PartitionIndex::empty(10);
assert_eq!(index.assign(42), 0);
assert_eq!(index.assign(42), 0);
}
#[test]
fn test_multiple_buckets() {
let mut index = PartitionIndex::empty(2);
assert_eq!(index.assign(1), 0);
assert_eq!(index.assign(2), 0);
assert_eq!(index.assign(3), 1);
assert_eq!(index.assign(4), 1);
assert_eq!(index.assign(5), 2);
}
#[tokio::test]
async fn test_hash_index_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = format!("file://{}", tmp.path().display());
let file_io = FileIO::from_url(&dir).unwrap().build().unwrap();
let hashes = vec![42, -1, 0, i32::MAX, i32::MIN];
let meta = HashIndexFile::write(&file_io, &dir, &hashes).await.unwrap();
assert_eq!(meta.index_type, HASH_INDEX);
assert_eq!(meta.row_count, 5);
assert_eq!(meta.file_size, 20);
let path = format!("{dir}/{}", meta.file_name);
let read_back = HashIndexFile::read(&file_io, &path).await.unwrap();
assert_eq!(read_back, hashes);
}
#[tokio::test]
async fn test_hash_index_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = format!("file://{}", tmp.path().display());
let file_io = FileIO::from_url(&dir).unwrap().build().unwrap();
let meta = HashIndexFile::write(&file_io, &dir, &[]).await.unwrap();
assert_eq!(meta.row_count, 0);
assert_eq!(meta.file_size, 0);
let path = format!("{dir}/{}", meta.file_name);
let read_back = HashIndexFile::read(&file_io, &path).await.unwrap();
assert!(read_back.is_empty());
}
}