use crate::config::HudiConfigs;
use crate::config::table::HudiTableConfig::TimelineLayoutVersion;
use crate::file_group::FileGroup;
use crate::timeline::completion_time::CompletionTimeView;
use crate::timeline::instant::Instant;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[derive(Debug)]
pub struct TimelineView {
as_of_timestamp: String,
#[allow(dead_code)]
start_timestamp: Option<String>,
excluding_file_groups: HashSet<FileGroup>,
request_to_completion: HashMap<String, String>,
completed_requests: HashSet<String>,
earliest_active_instant: Option<String>,
}
impl TimelineView {
pub fn new<'a, I>(
as_of_timestamp: String,
start_timestamp: Option<String>,
completed_commits: I,
excluding_file_groups: HashSet<FileGroup>,
hudi_configs: &Arc<HudiConfigs>,
) -> Self
where
I: IntoIterator<Item = &'a Instant>,
{
Self::new_with_archival_boundary(
as_of_timestamp,
start_timestamp,
completed_commits,
excluding_file_groups,
hudi_configs,
None,
)
}
pub fn new_with_archival_boundary<'a, I>(
as_of_timestamp: String,
start_timestamp: Option<String>,
completed_commits: I,
excluding_file_groups: HashSet<FileGroup>,
hudi_configs: &Arc<HudiConfigs>,
earliest_active_instant: Option<String>,
) -> Self
where
I: IntoIterator<Item = &'a Instant>,
{
let timeline_layout_version: isize = hudi_configs
.get(TimelineLayoutVersion)
.map(|v| v.into())
.unwrap_or(0);
let is_timeline_layout_v2 = timeline_layout_version >= 2;
let mut request_to_completion = HashMap::new();
let mut completed_requests = HashSet::new();
for instant in completed_commits {
completed_requests.insert(instant.timestamp.clone());
if is_timeline_layout_v2
&& let Some(completion_ts) = instant.completion_timestamp.as_ref()
{
request_to_completion.insert(instant.timestamp.clone(), completion_ts.clone());
}
}
Self {
as_of_timestamp,
start_timestamp,
excluding_file_groups,
request_to_completion,
completed_requests,
earliest_active_instant,
}
}
#[inline]
pub fn as_of_timestamp(&self) -> &str {
&self.as_of_timestamp
}
#[inline]
pub fn excluding_file_groups(&self) -> &HashSet<FileGroup> {
&self.excluding_file_groups
}
}
impl CompletionTimeView for TimelineView {
fn get_completion_time(&self, request_timestamp: &str) -> Option<&str> {
self.request_to_completion
.get(request_timestamp)
.map(|s| s.as_str())
}
fn is_committed(&self, request_timestamp: &str) -> bool {
if self.completed_requests.contains(request_timestamp) {
return true;
}
match &self.earliest_active_instant {
Some(boundary) => request_timestamp < boundary.as_str(),
None => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::HudiConfigs;
use crate::timeline::instant::{Action, State};
fn create_instant(request_ts: &str, completion_ts: Option<&str>) -> Instant {
Instant {
timestamp: request_ts.to_string(),
completion_timestamp: completion_ts.map(|s| s.to_string()),
action: Action::Commit,
state: State::Completed,
epoch_millis: 0,
}
}
fn create_layout_v1_configs() -> Arc<HudiConfigs> {
Arc::new(HudiConfigs::new([("hoodie.timeline.layout.version", "1")]))
}
fn create_layout_v2_configs() -> Arc<HudiConfigs> {
Arc::new(HudiConfigs::new([("hoodie.timeline.layout.version", "2")]))
}
#[test]
fn test_snapshot_view_creation_layout_v2() {
let instants = vec![
create_instant("20240101120000000", Some("20240101120005000")),
create_instant("20240101130000000", Some("20240101130010000")),
];
let configs = create_layout_v2_configs();
let view = TimelineView::new(
"20240101130000000".to_string(),
None,
&instants,
HashSet::new(),
&configs,
);
assert_eq!(view.as_of_timestamp(), "20240101130000000");
assert!(view.excluding_file_groups().is_empty());
assert_eq!(
view.get_completion_time("20240101120000000"),
Some("20240101120005000")
);
}
#[test]
fn test_snapshot_view_creation_layout_v1() {
let instants = vec![
create_instant("20240101120000000", Some("20240101120005000")),
create_instant("20240101130000000", Some("20240101130010000")),
];
let configs = create_layout_v1_configs();
let view = TimelineView::new(
"20240101130000000".to_string(),
None,
&instants,
HashSet::new(),
&configs,
);
assert_eq!(view.as_of_timestamp(), "20240101130000000");
assert!(view.get_completion_time("20240101120000000").is_none());
assert!(view.is_committed("20240101120000000"));
}
#[test]
fn test_completion_time_lookup_layout_v2() {
let instants = vec![
create_instant("20240101120000000", Some("20240101120005000")),
create_instant("20240101130000000", Some("20240101130010000")),
create_instant("20240101140000000", None), ];
let configs = create_layout_v2_configs();
let view = TimelineView::new(
"20240101140000000".to_string(),
None,
&instants,
HashSet::new(),
&configs,
);
assert_eq!(
view.get_completion_time("20240101120000000"),
Some("20240101120005000")
);
assert_eq!(
view.get_completion_time("20240101130000000"),
Some("20240101130010000")
);
assert!(view.get_completion_time("20240101140000000").is_none());
assert!(view.get_completion_time("unknown").is_none());
}
#[test]
fn test_is_committed_filters_pending_commits_on_both_layouts() {
let instants = vec![
create_instant("20240101120000000", Some("20240101120005000")),
create_instant("20240101140000000", Some("20240101140005000")),
];
for configs in [create_layout_v1_configs(), create_layout_v2_configs()] {
let view = TimelineView::new_with_archival_boundary(
"20240101140000000".to_string(),
None,
&instants,
HashSet::new(),
&configs,
Some("20240101120000000".to_string()),
);
assert!(view.is_committed("20240101120000000"), "completed commit");
assert!(view.is_committed("20240101140000000"), "completed commit");
assert!(
!view.is_committed("20240101130000000"),
"a commit that never completed must not be readable"
);
assert!(!view.is_committed("20240101150000000"), "in flight");
}
}
#[test]
fn test_is_committed_admits_archived_commits() {
let instants = vec![create_instant(
"20240101120000000",
Some("20240101120005000"),
)];
for configs in [create_layout_v1_configs(), create_layout_v2_configs()] {
let view = TimelineView::new_with_archival_boundary(
"20240101120000000".to_string(),
None,
&instants,
HashSet::new(),
&configs,
Some("20240101120000000".to_string()),
);
assert!(
view.is_committed("20231231000000000"),
"an archived commit's files must stay readable"
);
assert!(!view.is_committed("20240101130000000"));
}
}
#[test]
fn test_is_committed_without_a_boundary_is_conservative() {
let instants = vec![create_instant(
"20240101120000000",
Some("20240101120005000"),
)];
let configs = create_layout_v2_configs();
let view = TimelineView::new(
"20240101120000000".to_string(),
None,
&instants,
HashSet::new(),
&configs,
);
assert!(view.is_committed("20240101120000000"));
assert!(!view.is_committed("20231231000000000"));
}
#[test]
fn test_excluding_file_groups() {
let instants: Vec<Instant> = vec![];
let configs = create_layout_v2_configs();
let mut excludes = HashSet::new();
excludes.insert(FileGroup::new("file-id-1".to_string(), "p1".to_string()));
excludes.insert(FileGroup::new("file-id-2".to_string(), "p2".to_string()));
let view = TimelineView::new(
"20240101120000000".to_string(),
None,
&instants,
excludes,
&configs,
);
assert_eq!(view.excluding_file_groups().len(), 2);
}
}