Skip to main content

journal_engine/
indexing.rs

1//! Journal file indexing infrastructure.
2//!
3//! This module provides infrastructure for indexing journal files:
4//! - Batch parallel indexing with time budget enforcement
5//! - Cache builder for file indexes
6
7use crate::{
8    cache::{FileIndexCache, FileIndexKey},
9    error::{EngineError, Result},
10    facets::Facets,
11    query_time_range::QueryTimeRange,
12};
13use journal_index::{FieldName, FileIndex, FileIndexer, IndexingLimits, Seconds};
14use journal_registry::{File, Registry};
15use std::sync::Arc;
16use std::sync::atomic::AtomicUsize;
17use tokio_util::sync::CancellationToken;
18use tracing::{error, trace};
19
20const MAX_BATCH_INDEX_THREADS: usize = 4;
21
22// ============================================================================
23// File Index Cache Builder
24// ============================================================================
25
26/// Builder for constructing a FileIndexCache with custom configuration.
27pub struct FileIndexCacheBuilder {
28    cache_path: Option<std::path::PathBuf>,
29    cache_namespace: String,
30    memory_capacity: Option<usize>,
31    disk_capacity: Option<usize>,
32    block_size: Option<usize>,
33    enable_disk_cache: bool,
34}
35
36impl FileIndexCacheBuilder {
37    /// Creates a new builder with no configuration.
38    ///
39    /// All options use defaults if not explicitly set:
40    /// - Cache path: temp directory + "journal-engine-cache"
41    /// - Memory capacity: 128 entries
42    /// - Disk capacity: 16 MB
43    /// - Block size: 4 MB
44    pub fn new() -> Self {
45        Self {
46            cache_path: None,
47            cache_namespace: String::new(),
48            memory_capacity: None,
49            disk_capacity: None,
50            block_size: None,
51            enable_disk_cache: true,
52        }
53    }
54
55    /// Sets the cache directory path.
56    pub fn with_cache_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
57        self.cache_path = Some(path.into());
58        self
59    }
60
61    /// Sets the consumer-visible cache namespace.
62    ///
63    /// Use the returned namespace with [`FileIndexKey::new_with_namespace`] or
64    /// [`Self::file_index_key`] to force clean rebuilds for consumer semantic
65    /// migrations while keeping the same physical cache directory. The cache
66    /// object does not apply this namespace to keys constructed elsewhere.
67    pub fn with_cache_namespace(mut self, namespace: impl Into<String>) -> Self {
68        self.cache_namespace = namespace.into();
69        self
70    }
71
72    /// Returns the namespace configured on this builder.
73    pub fn cache_namespace(&self) -> &str {
74        &self.cache_namespace
75    }
76
77    /// Creates a file-index cache key using this builder's namespace.
78    pub fn file_index_key(
79        &self,
80        file: &File,
81        facets: &Facets,
82        source_timestamp_field: Option<FieldName>,
83    ) -> FileIndexKey {
84        FileIndexKey::new_with_namespace(
85            file,
86            facets,
87            source_timestamp_field,
88            self.cache_namespace.clone(),
89        )
90    }
91
92    /// Sets the memory capacity (number of items to keep in memory).
93    pub fn with_memory_capacity(mut self, capacity: usize) -> Self {
94        self.memory_capacity = Some(capacity);
95        self
96    }
97
98    /// Sets the disk capacity in bytes.
99    pub fn with_disk_capacity(mut self, capacity: usize) -> Self {
100        self.disk_capacity = Some(capacity);
101        self
102    }
103
104    /// Sets the block size in bytes.
105    pub fn with_block_size(mut self, size: usize) -> Self {
106        self.block_size = Some(size);
107        self
108    }
109
110    /// Disables the disk-backed cache and keeps indexes in memory only.
111    pub fn without_disk_cache(mut self) -> Self {
112        self.enable_disk_cache = false;
113        self
114    }
115
116    /// Builds the FileIndexCache with the configured settings.
117    pub async fn build(self) -> Result<FileIndexCache> {
118        use foyer::HybridCacheBuilder;
119
120        let memory_capacity = self.memory_capacity.unwrap_or(128);
121        let memory = HybridCacheBuilder::new()
122            .with_name("file-index-cache")
123            .with_policy(foyer::HybridCachePolicy::WriteOnInsertion)
124            .memory(memory_capacity)
125            .with_shards(4);
126
127        if !self.enable_disk_cache {
128            return memory.storage().build().await.map_err(Into::into);
129        }
130
131        use foyer::{
132            BlockEngineBuilder, DeviceBuilder, FsDeviceBuilder, IoEngineBuilder,
133            PsyncIoEngineBuilder,
134        };
135
136        let cache_path = self
137            .cache_path
138            // nosemgrep: rust.lang.security.temp-dir.temp-dir -- caller-configurable non-sensitive disk cache default.
139            .unwrap_or_else(|| std::env::temp_dir().join("journal-engine-cache"));
140        let disk_capacity = self.disk_capacity.unwrap_or(16 * 1024 * 1024);
141        let block_size = self.block_size.unwrap_or(4 * 1024 * 1024);
142
143        std::fs::create_dir_all(&cache_path).map_err(|e| {
144            EngineError::Io(std::io::Error::other(format!(
145                "Failed to create cache directory: {}",
146                e
147            )))
148        })?;
149
150        let cache = memory
151            .storage()
152            .with_io_engine(PsyncIoEngineBuilder::new().build().await?)
153            .with_engine_config(
154                BlockEngineBuilder::new(
155                    FsDeviceBuilder::new(&cache_path)
156                        .with_capacity(disk_capacity)
157                        .build()?,
158                )
159                .with_block_size(block_size),
160            )
161            .build()
162            .await?;
163
164        Ok(cache)
165    }
166}
167
168impl Default for FileIndexCacheBuilder {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use tempfile::tempdir;
178
179    #[tokio::test(flavor = "current_thread")]
180    async fn build_without_disk_cache_does_not_create_disk_cache_files() {
181        let tmp = tempdir().expect("tempdir");
182        let cache_path = tmp.path().join("foyer-cache");
183        let cache = FileIndexCacheBuilder::new()
184            .with_cache_path(&cache_path)
185            .with_memory_capacity(4)
186            .without_disk_cache()
187            .build()
188            .await
189            .expect("build in-memory file index cache");
190
191        cache
192            .close()
193            .await
194            .expect("close in-memory file index cache");
195        assert!(
196            !cache_path.exists(),
197            "expected memory-only file index cache to avoid creating {}",
198            cache_path.display()
199        );
200    }
201
202    #[test]
203    fn builder_records_consumer_cache_namespace() {
204        let builder = FileIndexCacheBuilder::new().with_cache_namespace("netdata-v2");
205
206        assert_eq!(builder.cache_namespace(), "netdata-v2");
207    }
208
209    #[test]
210    fn builder_creates_namespaced_keys() {
211        let file = journal_registry::File::from_path(std::path::Path::new(
212            "/var/log/journal/00112233445566778899aabbccddeeff/system.journal",
213        ))
214        .expect("valid journal file path");
215        let facets = Facets::new(&[]);
216        let default_key = FileIndexKey::new(&file, &facets, None);
217        let builder_key = FileIndexCacheBuilder::new()
218            .with_cache_namespace("consumer-v2")
219            .file_index_key(&file, &facets, None);
220
221        assert_ne!(default_key, builder_key);
222    }
223
224    #[test]
225    fn cache_lookup_error_is_recomputed_as_miss() {
226        let file = journal_registry::File::from_path(std::path::Path::new(
227            "/var/log/journal/00112233445566778899aabbccddeeff/system.journal",
228        ))
229        .expect("valid journal file path");
230        let facets = Facets::new(&[]);
231        let key = FileIndexKey::new(&file, &facets, None);
232
233        let partition = partition_cache_results(
234            vec![(
235                key.clone(),
236                Err(EngineError::Io(std::io::Error::other(
237                    "cache lookup failed",
238                ))),
239            )],
240            1,
241            Seconds::new(60),
242        );
243
244        assert_eq!(partition.responses.len(), 0);
245        assert_eq!(partition.keys_to_compute, vec![key]);
246        assert_eq!(partition.stats.cache_misses, 1);
247    }
248}
249
250// ============================================================================
251// Batch Processing
252// ============================================================================
253
254/// Batch computes file indexes in parallel using rayon, with cache checking and time budget enforcement.
255///
256/// This function:
257/// 1. Checks cache for all keys upfront
258/// 2. Identifies cache misses
259/// 3. Uses tokio::task to compute missing indexes in parallel
260/// 4. Inserts newly computed indexes into cache
261/// 5. Returns all results (cached + newly computed)
262///
263/// # Arguments
264/// * `cache` - The file index cache
265/// * `registry` - Registry to update with file metadata
266/// * `keys` - Vector of (file, facets, source_timestamp_field) to fetch/compute indexes for
267/// * `time_range` - Query time range for bucket duration calculation
268/// * `cancellation` - Token to signal cancellation from the caller
269/// * `indexing_limits` - Configuration limits for indexing (cardinality, payload size)
270/// * `progress_counter` - Optional atomic counter incremented after each file is indexed
271///
272/// # Returns
273/// Vector of responses for each key. Successful responses contain the file index.
274/// If cancelled, returns Cancelled error.
275pub async fn batch_compute_file_indexes(
276    cache: &FileIndexCache,
277    registry: &Registry,
278    keys: Vec<FileIndexKey>,
279    time_range: &QueryTimeRange,
280    cancellation: CancellationToken,
281    indexing_limits: IndexingLimits,
282    progress_counter: Option<Arc<AtomicUsize>>,
283) -> Result<Vec<(FileIndexKey, FileIndex)>> {
284    let bucket_duration = time_range.bucket_duration_seconds();
285    let cache_lookup_results = lookup_cached_indexes(cache, &keys, &cancellation).await?;
286    let CachePartition {
287        mut responses,
288        keys_to_compute,
289        stats,
290    } = partition_cache_results(cache_lookup_results, keys.len(), bucket_duration);
291
292    if cancellation.is_cancelled() {
293        return Err(EngineError::Cancelled);
294    }
295
296    trace!(
297        "phase 2 summary: hits={}, misses={}, stale={}, incompatible_bucket={}",
298        stats.cache_hits, stats.cache_misses, stats.stale_entries, stats.incompatible_bucket
299    );
300
301    let computed_results = compute_missing_indexes(
302        keys_to_compute,
303        bucket_duration,
304        cancellation.clone(),
305        indexing_limits,
306        progress_counter,
307    )
308    .await?;
309
310    store_computed_indexes(registry, cache, &mut responses, computed_results);
311    Ok(responses)
312}
313
314async fn lookup_cached_indexes(
315    cache: &FileIndexCache,
316    keys: &[FileIndexKey],
317    cancellation: &CancellationToken,
318) -> Result<Vec<(FileIndexKey, Result<Option<FileIndex>>)>> {
319    let cache_lookup_futures = keys.iter().map(|key| {
320        let key_clone = key.clone();
321        async move {
322            let cached = cache
323                .get(&key_clone)
324                .await
325                .map(|entry| entry.map(|e| e.value().clone()))
326                .map_err(|e| e.into());
327            (key_clone, cached)
328        }
329    });
330
331    tokio::select! {
332        results = futures::future::join_all(cache_lookup_futures) => Ok(results),
333        _ = cancellation.cancelled() => Err(EngineError::Cancelled),
334    }
335}
336
337#[derive(Default)]
338struct CacheStats {
339    cache_hits: usize,
340    cache_misses: usize,
341    stale_entries: usize,
342    incompatible_bucket: usize,
343}
344
345struct CachePartition {
346    responses: Vec<(FileIndexKey, FileIndex)>,
347    keys_to_compute: Vec<FileIndexKey>,
348    stats: CacheStats,
349}
350
351fn partition_cache_results(
352    cache_lookup_results: Vec<(FileIndexKey, Result<Option<FileIndex>>)>,
353    key_count: usize,
354    bucket_duration: Seconds,
355) -> CachePartition {
356    let mut partition = CachePartition {
357        responses: Vec::with_capacity(key_count),
358        keys_to_compute: Vec::new(),
359        stats: CacheStats::default(),
360    };
361
362    for (key, cache_lookup_result) in cache_lookup_results {
363        partition_cache_result(key, cache_lookup_result, bucket_duration, &mut partition);
364    }
365
366    partition
367}
368
369fn partition_cache_result(
370    key: FileIndexKey,
371    cache_lookup_result: Result<Option<FileIndex>>,
372    bucket_duration: Seconds,
373    partition: &mut CachePartition,
374) {
375    match cache_lookup_result {
376        Ok(Some(file_index)) => partition_cached_index(key, file_index, bucket_duration, partition),
377        Ok(None) => {
378            partition.stats.cache_misses += 1;
379            partition.keys_to_compute.push(key);
380        }
381        Err(e) => {
382            error!("cached file index lookup error {}; recomputing", e);
383            partition.stats.cache_misses += 1;
384            partition.keys_to_compute.push(key);
385        }
386    }
387}
388
389fn partition_cached_index(
390    key: FileIndexKey,
391    file_index: FileIndex,
392    bucket_duration: Seconds,
393    partition: &mut CachePartition,
394) {
395    let fresh = file_index.is_fresh();
396    let bucket_ok = compatible_bucket_duration(&file_index, bucket_duration);
397
398    if fresh && bucket_ok {
399        partition.stats.cache_hits += 1;
400        partition.responses.push((key, file_index));
401        return;
402    }
403
404    if !fresh {
405        partition.stats.stale_entries += 1;
406    }
407    if !bucket_ok {
408        partition.stats.incompatible_bucket += 1;
409    }
410    partition.keys_to_compute.push(key);
411}
412
413fn compatible_bucket_duration(file_index: &FileIndex, bucket_duration: Seconds) -> bool {
414    file_index.bucket_duration() <= bucket_duration
415        && bucket_duration.is_multiple_of(file_index.bucket_duration())
416}
417
418async fn compute_missing_indexes(
419    keys_to_compute: Vec<FileIndexKey>,
420    bucket_duration: Seconds,
421    cancellation: CancellationToken,
422    indexing_limits: IndexingLimits,
423    progress_counter: Option<Arc<AtomicUsize>>,
424) -> Result<Vec<(FileIndexKey, Result<FileIndex>)>> {
425    let compute_threads = compute_thread_count(keys_to_compute.len());
426    let cancellation_for_select = cancellation.clone();
427    let compute_task = tokio::task::spawn_blocking(move || {
428        compute_missing_indexes_blocking(
429            keys_to_compute,
430            bucket_duration,
431            cancellation,
432            indexing_limits,
433            progress_counter,
434            compute_threads,
435        )
436    });
437
438    tokio::select! {
439        result = compute_task => match result {
440            Ok(result) => result,
441            Err(e) => Err(EngineError::Io(std::io::Error::other(format!(
442                "Blocking task panicked: {}",
443                e
444            )))),
445        },
446        _ = cancellation_for_select.cancelled() => Err(EngineError::Cancelled),
447    }
448}
449
450fn compute_thread_count(key_count: usize) -> usize {
451    key_count.max(1).min(
452        std::thread::available_parallelism()
453            .map(|value| value.get())
454            .unwrap_or(1)
455            .min(MAX_BATCH_INDEX_THREADS),
456    )
457}
458
459fn compute_missing_indexes_blocking(
460    keys_to_compute: Vec<FileIndexKey>,
461    bucket_duration: Seconds,
462    cancellation: CancellationToken,
463    indexing_limits: IndexingLimits,
464    progress_counter: Option<Arc<AtomicUsize>>,
465    compute_threads: usize,
466) -> Result<Vec<(FileIndexKey, Result<FileIndex>)>> {
467    use rayon::prelude::*;
468    use std::sync::Arc;
469    use std::sync::atomic::AtomicBool;
470
471    let cancelled = Arc::new(AtomicBool::new(false));
472    let thread_pool = build_index_thread_pool(compute_threads)?;
473
474    Ok(thread_pool.install(|| {
475        keys_to_compute
476            .into_par_iter()
477            .map(|key| {
478                compute_one_index(
479                    key,
480                    bucket_duration,
481                    &cancellation,
482                    indexing_limits,
483                    progress_counter.as_ref(),
484                    &cancelled,
485                )
486            })
487            .collect::<Vec<(FileIndexKey, Result<FileIndex>)>>()
488    }))
489}
490
491fn build_index_thread_pool(compute_threads: usize) -> Result<rayon::ThreadPool> {
492    // Build a bounded local pool per call instead of using Rayon’s global pool.
493    // The global pool previously stayed alive after rebuild/indexing and kept a
494    // full worker set plus allocator arenas resident in the plugin process.
495    rayon::ThreadPoolBuilder::new()
496        .num_threads(compute_threads)
497        .build()
498        .map_err(|err| {
499            EngineError::Io(std::io::Error::other(format!(
500                "failed to build rayon index pool: {}",
501                err
502            )))
503        })
504}
505
506fn compute_one_index(
507    key: FileIndexKey,
508    bucket_duration: Seconds,
509    cancellation: &CancellationToken,
510    indexing_limits: IndexingLimits,
511    progress_counter: Option<&Arc<AtomicUsize>>,
512    cancelled: &std::sync::atomic::AtomicBool,
513) -> (FileIndexKey, Result<FileIndex>) {
514    if cancellation.is_cancelled() || cancelled.load(std::sync::atomic::Ordering::Relaxed) {
515        cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
516        return (key, Err(EngineError::Cancelled));
517    }
518
519    let result = index_one_file(&key, bucket_duration, indexing_limits);
520    if result.is_ok()
521        && let Some(counter) = progress_counter
522    {
523        counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
524    }
525
526    (key, result)
527}
528
529fn index_one_file(
530    key: &FileIndexKey,
531    bucket_duration: Seconds,
532    indexing_limits: IndexingLimits,
533) -> Result<FileIndex> {
534    FileIndexer::new(indexing_limits)
535        .index(
536            &key.file,
537            key.source_timestamp_field.as_ref(),
538            key.facets.as_slice(),
539            bucket_duration,
540        )
541        .map_err(|e| e.into())
542}
543
544fn store_computed_indexes(
545    registry: &Registry,
546    cache: &FileIndexCache,
547    responses: &mut Vec<(FileIndexKey, FileIndex)>,
548    computed_results: Vec<(FileIndexKey, Result<FileIndex>)>,
549) {
550    for (key, response) in computed_results {
551        match response {
552            Ok(index) => {
553                update_registry_time_range(registry, &key, &index);
554                cache.insert(key.clone(), index.clone());
555                responses.push((key, index));
556            }
557            Err(e) => {
558                error!(
559                    "file index computation failed for file={}: {}",
560                    key.file.path(),
561                    e
562                );
563            }
564        }
565    }
566}
567
568fn update_registry_time_range(registry: &Registry, key: &FileIndexKey, index: &FileIndex) {
569    registry.update_time_range(
570        &key.file,
571        index.start_time(),
572        index.end_time(),
573        index.indexed_at(),
574        index.online(),
575    );
576}