use crate::{
Bitmap, FieldName, FieldValuePair, FileIndex, Histogram, IndexError, Microseconds, Result,
Seconds,
};
use journal_core::collections::{HashMap, HashSet};
use journal_core::file::{HashableObject, JournalFile, Mmap, offset_array::InlinedCursor};
use journal_registry::File;
use std::num::NonZeroU64;
use tracing::{error, trace, warn};
pub const DEFAULT_MAX_UNIQUE_VALUES_PER_FIELD: usize = 500;
pub const DEFAULT_MAX_FIELD_PAYLOAD_SIZE: usize = 100;
#[derive(Debug, Clone, Copy)]
pub struct IndexingLimits {
pub max_unique_values_per_field: usize,
pub max_field_payload_size: usize,
}
impl Default for IndexingLimits {
fn default() -> Self {
Self {
max_unique_values_per_field: DEFAULT_MAX_UNIQUE_VALUES_PER_FIELD,
max_field_payload_size: DEFAULT_MAX_FIELD_PAYLOAD_SIZE,
}
}
}
#[derive(Debug)]
#[cfg_attr(feature = "allocative", derive(allocative::Allocative))]
pub struct FileIndexer {
limits: IndexingLimits,
source_timestamp_cursor_pairs: Vec<(Microseconds, InlinedCursor)>,
entry_offsets: Vec<NonZeroU64>,
source_timestamp_entry_offset_pairs: Vec<(Microseconds, NonZeroU64)>,
realtime_entry_offset_pairs: Vec<(Microseconds, NonZeroU64)>,
entry_indices: Vec<u32>,
entry_offset_index: HashMap<NonZeroU64, u64>,
}
impl Default for FileIndexer {
fn default() -> Self {
Self::new(IndexingLimits::default())
}
}
impl FileIndexer {
pub fn new(limits: IndexingLimits) -> Self {
Self {
limits,
source_timestamp_cursor_pairs: Vec::new(),
entry_offsets: Vec::new(),
source_timestamp_entry_offset_pairs: Vec::new(),
realtime_entry_offset_pairs: Vec::new(),
entry_indices: Vec::new(),
entry_offset_index: HashMap::default(),
}
}
}
impl FileIndexer {
pub fn index(
&mut self,
file: &File,
source_timestamp_field: Option<&FieldName>,
field_names: &[FieldName],
bucket_duration: Seconds,
) -> Result<FileIndex> {
self.source_timestamp_cursor_pairs = Vec::new();
self.source_timestamp_entry_offset_pairs = Vec::new();
self.realtime_entry_offset_pairs = Vec::new();
self.entry_indices = Vec::new();
self.entry_offsets = Vec::new();
self.entry_offset_index = HashMap::default();
let window_size = 32 * 1024 * 1024;
let journal_file = JournalFile::<Mmap>::open(file, window_size)?;
let Some(tail_object_offset) = journal_file.journal_header_ref().tail_object_offset else {
return Err(IndexError::MissingOffset);
};
let indexed_at = Seconds::now();
let was_online = journal_file.journal_header_ref().state == 1 || file.is_active();
let histogram = self.build_histogram(
&journal_file,
source_timestamp_field,
bucket_duration,
tail_object_offset,
)?;
let entry_offsets = self
.source_timestamp_entry_offset_pairs
.iter()
.map(|(_, entry_offset)| entry_offset.get() as u32)
.collect();
let entries =
self.build_entries_index(&journal_file, field_names, tail_object_offset, was_online)?;
let indexed_fields: HashSet<FieldName> = field_names.iter().cloned().collect();
let mut file_fields = HashSet::default();
for field in journal_file.fields() {
let field = field?;
let field_name = String::from_utf8_lossy(field.raw_payload()).into_owned();
file_fields.insert(FieldName::new_unchecked(field_name));
}
Ok(FileIndex::new(
file.clone(),
indexed_at,
was_online,
histogram,
entry_offsets,
file_fields,
indexed_fields,
entries,
))
}
fn build_entries_index(
&mut self,
journal_file: &JournalFile<Mmap>,
field_names: &[FieldName],
tail_object_offset: NonZeroU64,
was_online: bool,
) -> Result<HashMap<FieldValuePair, Bitmap>> {
let mut entries_index = HashMap::default();
let mut issues = FieldIndexIssues::default();
for field_name in field_names {
self.index_field_values(
journal_file,
field_name,
tail_object_offset,
&mut entries_index,
&mut issues,
)?;
}
issues.log(journal_file, self.limits, was_online);
Ok(entries_index)
}
fn index_field_values<'a>(
&mut self,
journal_file: &JournalFile<Mmap>,
field_name: &'a FieldName,
tail_object_offset: NonZeroU64,
entries_index: &mut HashMap<FieldValuePair, Bitmap>,
issues: &mut FieldIndexIssues<'a>,
) -> Result<()> {
let field_data_iterator = match journal_file.field_data_objects(field_name.as_bytes()) {
Ok(field_data_iterator) => field_data_iterator,
Err(e) => {
warn!(
"failed to iterate field data objects for field '{}' in file {}: {:#?}",
field_name,
journal_file.file().path(),
e
);
return Ok(());
}
};
let mut state = FieldIndexState::default();
for data_object in field_data_iterator {
if state.unique_values_count >= self.limits.max_unique_values_per_field {
state.was_truncated = true;
break;
}
let (data_payload, inlined_cursor) = {
let Ok(data_object) = data_object else {
continue;
};
if data_object.raw_payload().len() >= self.limits.max_field_payload_size
|| data_object.is_compressed()
{
state.ignored_large_payloads += 1;
continue;
}
let data_payload = String::from_utf8_lossy(data_object.raw_payload()).into_owned();
let Some(inlined_cursor) = data_object.inlined_cursor() else {
continue;
};
(data_payload, inlined_cursor)
};
let Some(pair) = FieldValuePair::parse(&data_payload) else {
warn!("Invalid field=value format: {}", data_payload);
continue;
};
if self.collect_data_entry_indices(journal_file, inlined_cursor, tail_object_offset)? {
insert_field_bitmap(entries_index, field_name, pair.value(), &self.entry_indices);
state.unique_values_count += 1;
}
}
issues.record(field_name, state);
Ok(())
}
fn collect_data_entry_indices(
&mut self,
journal_file: &JournalFile<Mmap>,
inlined_cursor: InlinedCursor,
tail_object_offset: NonZeroU64,
) -> Result<bool> {
self.entry_offsets.clear();
if let Err(err) = inlined_cursor.collect_offsets(journal_file, &mut self.entry_offsets) {
warn!("failed to collect entry offsets from DATA object index: {err:?}");
return Ok(false);
}
self.entry_indices.clear();
for entry_offset in self
.entry_offsets
.iter()
.copied()
.filter(|offset| *offset <= tail_object_offset)
{
let Some(entry_index) = self.entry_offset_index.get(&entry_offset) else {
panic!(
"missing entry offset {} from index (total offsets: {})",
entry_offset,
self.entry_offset_index.len()
);
};
self.entry_indices.push(*entry_index as u32);
}
if self.entry_indices.is_empty() {
return Ok(false);
}
self.entry_indices.sort_unstable();
Ok(true)
}
fn collect_source_field_info(
&mut self,
journal_file: &JournalFile<Mmap>,
source_field_name: &[u8],
) -> Result<()> {
let field_data_iterator = journal_file.field_data_objects(source_field_name)?;
self.source_timestamp_cursor_pairs.clear();
for data_object_result in field_data_iterator {
let Ok(data_object) = data_object_result else {
warn!("loading data object failed");
continue;
};
let Ok(source_timestamp) =
crate::field_types::parse_timestamp(source_field_name, &data_object)
else {
warn!("parsing source timestamp failed");
continue;
};
let Some(ic) = data_object.inlined_cursor() else {
use journal_core::file::JournalState;
let file_state = JournalState::try_from(journal_file.journal_header_ref().state)
.map(|s| s.to_string())
.unwrap_or_else(|_| "UNKNOWN".to_string());
warn!(
"orphaned data object (no entries) for _SOURCE_REALTIME_TIMESTAMP={} in {} (state: {})",
source_timestamp,
journal_file.file().path(),
file_state
);
continue;
};
self.source_timestamp_cursor_pairs
.push((Microseconds(source_timestamp), ic));
}
self.source_timestamp_entry_offset_pairs.clear();
for (ts, ic) in self.source_timestamp_cursor_pairs.iter() {
self.entry_offsets.clear();
match ic.collect_offsets(journal_file, &mut self.entry_offsets) {
Ok(_) => {}
Err(e) => {
error!("failed to collect offsets from source timestamp: {}", e);
continue;
}
}
for entry_offset in &self.entry_offsets {
self.source_timestamp_entry_offset_pairs
.push((*ts, *entry_offset));
}
}
self.source_timestamp_entry_offset_pairs.sort_unstable();
for (idx, (_, entry_offset)) in self.source_timestamp_entry_offset_pairs.iter().enumerate()
{
self.entry_offset_index.insert(*entry_offset, idx as _);
}
Ok(())
}
fn build_histogram(
&mut self,
journal_file: &JournalFile<Mmap>,
source_timestamp_field_name: Option<&FieldName>,
bucket_duration: Seconds,
tail_object_offset: NonZeroU64,
) -> Result<Histogram> {
if let Some(source_field_name) = source_timestamp_field_name {
self.collect_source_field_info(journal_file, source_field_name.as_bytes())?;
}
self.entry_offsets.clear();
journal_file.entry_offsets(&mut self.entry_offsets)?;
self.realtime_entry_offset_pairs.clear();
for entry_offset in self
.entry_offsets
.iter()
.copied()
.filter(|offset| *offset <= tail_object_offset)
{
if self.entry_offset_index.contains_key(&entry_offset) {
continue;
}
let timestamp = {
let entry = journal_file.entry_ref(entry_offset)?;
entry.header.realtime
};
self.realtime_entry_offset_pairs
.push((Microseconds(timestamp), entry_offset));
}
if !self.realtime_entry_offset_pairs.is_empty() {
self.source_timestamp_entry_offset_pairs
.append(&mut self.realtime_entry_offset_pairs);
self.source_timestamp_entry_offset_pairs.sort_unstable();
self.entry_offset_index.clear();
for (idx, (_, entry_offset)) in
self.source_timestamp_entry_offset_pairs.iter().enumerate()
{
self.entry_offset_index.insert(*entry_offset, idx as _);
}
}
Histogram::from_timestamp_offset_pairs(
bucket_duration,
self.source_timestamp_entry_offset_pairs.as_slice(),
)
}
}
#[derive(Default)]
struct FieldIndexState {
unique_values_count: usize,
ignored_large_payloads: usize,
was_truncated: bool,
}
#[derive(Default)]
struct FieldIndexIssues<'a> {
truncated_fields: Vec<&'a FieldName>,
fields_with_large_payloads: Vec<&'a FieldName>,
}
impl<'a> FieldIndexIssues<'a> {
fn record(&mut self, field_name: &'a FieldName, state: FieldIndexState) {
if state.was_truncated {
self.truncated_fields.push(field_name);
}
if state.ignored_large_payloads > 0 {
self.fields_with_large_payloads.push(field_name);
}
}
fn log(&self, journal_file: &JournalFile<Mmap>, limits: IndexingLimits, was_online: bool) {
self.log_truncated_fields(journal_file, limits, was_online);
self.log_large_payload_fields(journal_file, was_online);
}
fn log_truncated_fields(
&self,
journal_file: &JournalFile<Mmap>,
limits: IndexingLimits,
was_online: bool,
) {
if self.truncated_fields.is_empty() {
return;
}
let field_names: Vec<&str> = self.truncated_fields.iter().map(|f| f.as_str()).collect();
let msg = format!(
"File '{}': {} field(s) truncated due to cardinality limit ({}): {:?}",
journal_file.file().path(),
self.truncated_fields.len(),
limits.max_unique_values_per_field,
field_names
);
if was_online {
trace!("{msg}");
} else {
warn!("{msg}");
}
}
fn log_large_payload_fields(&self, journal_file: &JournalFile<Mmap>, was_online: bool) {
if self.fields_with_large_payloads.is_empty() {
return;
}
let field_names: Vec<&str> = self
.fields_with_large_payloads
.iter()
.map(|f| f.as_str())
.collect();
let msg = format!(
"File '{}': {} field(s) had values skipped due to large payloads: {:?}",
journal_file.file().path(),
self.fields_with_large_payloads.len(),
field_names
);
if was_online {
trace!("{msg}");
} else {
tracing::info!("{msg}");
}
}
}
fn insert_field_bitmap(
entries_index: &mut HashMap<FieldValuePair, Bitmap>,
field_name: &FieldName,
value: &str,
entry_indices: &[u32],
) {
let mut bitmap =
Bitmap::from_sorted_iter(entry_indices.iter().copied()).expect("sorted entry indices");
bitmap.optimize();
let field_name = FieldName::new_unchecked(field_name);
let key = FieldValuePair::new_unchecked(field_name, value.to_string());
entries_index.insert(key, bitmap);
}