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::{BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, PsyncIoEngineConfig};
132
133        let cache_path = self
134            .cache_path
135            // nosemgrep: rust.lang.security.temp-dir.temp-dir -- caller-configurable non-sensitive disk cache default.
136            .unwrap_or_else(|| std::env::temp_dir().join("journal-engine-cache"));
137        let disk_capacity = self.disk_capacity.unwrap_or(16 * 1024 * 1024);
138        let block_size = self.block_size.unwrap_or(4 * 1024 * 1024);
139
140        std::fs::create_dir_all(&cache_path).map_err(|e| {
141            EngineError::Io(std::io::Error::other(format!(
142                "Failed to create cache directory: {}",
143                e
144            )))
145        })?;
146
147        let cache = memory
148            .storage()
149            .with_io_engine_config(PsyncIoEngineConfig::new())
150            .with_engine_config(
151                BlockEngineConfig::new(
152                    FsDeviceBuilder::new(&cache_path)
153                        .with_capacity(disk_capacity)
154                        .build()?,
155                )
156                .with_block_size(block_size),
157            )
158            .build()
159            .await?;
160
161        Ok(cache)
162    }
163}
164
165impl Default for FileIndexCacheBuilder {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use tempfile::tempdir;
175
176    #[tokio::test(flavor = "current_thread")]
177    async fn build_without_disk_cache_does_not_create_disk_cache_files() {
178        let tmp = tempdir().expect("tempdir");
179        let cache_path = tmp.path().join("foyer-cache");
180        let cache = FileIndexCacheBuilder::new()
181            .with_cache_path(&cache_path)
182            .with_memory_capacity(4)
183            .without_disk_cache()
184            .build()
185            .await
186            .expect("build in-memory file index cache");
187
188        cache
189            .close()
190            .await
191            .expect("close in-memory file index cache");
192        assert!(
193            !cache_path.exists(),
194            "expected memory-only file index cache to avoid creating {}",
195            cache_path.display()
196        );
197    }
198
199    #[test]
200    fn builder_records_consumer_cache_namespace() {
201        let builder = FileIndexCacheBuilder::new().with_cache_namespace("netdata-v2");
202
203        assert_eq!(builder.cache_namespace(), "netdata-v2");
204    }
205
206    #[test]
207    fn builder_creates_namespaced_keys() {
208        let file = journal_registry::File::from_path(std::path::Path::new(
209            "/var/log/journal/00112233445566778899aabbccddeeff/system.journal",
210        ))
211        .expect("valid journal file path");
212        let facets = Facets::new(&[]);
213        let default_key = FileIndexKey::new(&file, &facets, None);
214        let builder_key = FileIndexCacheBuilder::new()
215            .with_cache_namespace("consumer-v2")
216            .file_index_key(&file, &facets, None);
217
218        assert_ne!(default_key, builder_key);
219    }
220
221    #[test]
222    fn cache_lookup_error_is_recomputed_as_miss() {
223        let file = journal_registry::File::from_path(std::path::Path::new(
224            "/var/log/journal/00112233445566778899aabbccddeeff/system.journal",
225        ))
226        .expect("valid journal file path");
227        let facets = Facets::new(&[]);
228        let key = FileIndexKey::new(&file, &facets, None);
229
230        let partition = partition_cache_results(
231            vec![(
232                key.clone(),
233                Err(EngineError::Io(std::io::Error::other(
234                    "cache lookup failed",
235                ))),
236            )],
237            1,
238            Seconds::new(60),
239        );
240
241        assert_eq!(partition.responses.len(), 0);
242        assert_eq!(partition.keys_to_compute, vec![key]);
243        assert_eq!(partition.stats.cache_misses, 1);
244    }
245}
246
247// ============================================================================
248// Batch Processing
249// ============================================================================
250
251/// Batch computes file indexes in parallel using rayon, with cache checking and time budget enforcement.
252///
253/// This function:
254/// 1. Checks cache for all keys upfront
255/// 2. Identifies cache misses
256/// 3. Uses tokio::task to compute missing indexes in parallel
257/// 4. Inserts newly computed indexes into cache
258/// 5. Returns all results (cached + newly computed)
259///
260/// # Arguments
261/// * `cache` - The file index cache
262/// * `registry` - Registry to update with file metadata
263/// * `keys` - Vector of (file, facets, source_timestamp_field) to fetch/compute indexes for
264/// * `time_range` - Query time range for bucket duration calculation
265/// * `cancellation` - Token to signal cancellation from the caller
266/// * `indexing_limits` - Configuration limits for indexing (cardinality, payload size)
267/// * `progress_counter` - Optional atomic counter incremented after each file is indexed
268///
269/// # Returns
270/// Vector of responses for each key. Successful responses contain the file index.
271/// If cancelled, returns Cancelled error.
272pub async fn batch_compute_file_indexes(
273    cache: &FileIndexCache,
274    registry: &Registry,
275    keys: Vec<FileIndexKey>,
276    time_range: &QueryTimeRange,
277    cancellation: CancellationToken,
278    indexing_limits: IndexingLimits,
279    progress_counter: Option<Arc<AtomicUsize>>,
280) -> Result<Vec<(FileIndexKey, FileIndex)>> {
281    let bucket_duration = time_range.bucket_duration_seconds();
282    let cache_lookup_results = lookup_cached_indexes(cache, &keys, &cancellation).await?;
283    let CachePartition {
284        mut responses,
285        keys_to_compute,
286        stats,
287    } = partition_cache_results(cache_lookup_results, keys.len(), bucket_duration);
288
289    if cancellation.is_cancelled() {
290        return Err(EngineError::Cancelled);
291    }
292
293    trace!(
294        "phase 2 summary: hits={}, misses={}, stale={}, incompatible_bucket={}",
295        stats.cache_hits, stats.cache_misses, stats.stale_entries, stats.incompatible_bucket
296    );
297
298    let computed_results = compute_missing_indexes(
299        keys_to_compute,
300        bucket_duration,
301        cancellation.clone(),
302        indexing_limits,
303        progress_counter,
304    )
305    .await?;
306
307    store_computed_indexes(registry, cache, &mut responses, computed_results);
308    Ok(responses)
309}
310
311async fn lookup_cached_indexes(
312    cache: &FileIndexCache,
313    keys: &[FileIndexKey],
314    cancellation: &CancellationToken,
315) -> Result<Vec<(FileIndexKey, Result<Option<FileIndex>>)>> {
316    let cache_lookup_futures = keys.iter().map(|key| {
317        let key_clone = key.clone();
318        async move {
319            let cached = cache
320                .get(&key_clone)
321                .await
322                .map(|entry| entry.map(|e| e.value().clone()))
323                .map_err(|e| e.into());
324            (key_clone, cached)
325        }
326    });
327
328    tokio::select! {
329        results = futures::future::join_all(cache_lookup_futures) => Ok(results),
330        _ = cancellation.cancelled() => Err(EngineError::Cancelled),
331    }
332}
333
334#[derive(Default)]
335struct CacheStats {
336    cache_hits: usize,
337    cache_misses: usize,
338    stale_entries: usize,
339    incompatible_bucket: usize,
340}
341
342struct CachePartition {
343    responses: Vec<(FileIndexKey, FileIndex)>,
344    keys_to_compute: Vec<FileIndexKey>,
345    stats: CacheStats,
346}
347
348fn partition_cache_results(
349    cache_lookup_results: Vec<(FileIndexKey, Result<Option<FileIndex>>)>,
350    key_count: usize,
351    bucket_duration: Seconds,
352) -> CachePartition {
353    let mut partition = CachePartition {
354        responses: Vec::with_capacity(key_count),
355        keys_to_compute: Vec::new(),
356        stats: CacheStats::default(),
357    };
358
359    for (key, cache_lookup_result) in cache_lookup_results {
360        partition_cache_result(key, cache_lookup_result, bucket_duration, &mut partition);
361    }
362
363    partition
364}
365
366fn partition_cache_result(
367    key: FileIndexKey,
368    cache_lookup_result: Result<Option<FileIndex>>,
369    bucket_duration: Seconds,
370    partition: &mut CachePartition,
371) {
372    match cache_lookup_result {
373        Ok(Some(file_index)) => partition_cached_index(key, file_index, bucket_duration, partition),
374        Ok(None) => {
375            partition.stats.cache_misses += 1;
376            partition.keys_to_compute.push(key);
377        }
378        Err(e) => {
379            error!("cached file index lookup error {}; recomputing", e);
380            partition.stats.cache_misses += 1;
381            partition.keys_to_compute.push(key);
382        }
383    }
384}
385
386fn partition_cached_index(
387    key: FileIndexKey,
388    file_index: FileIndex,
389    bucket_duration: Seconds,
390    partition: &mut CachePartition,
391) {
392    let fresh = file_index.is_fresh();
393    let bucket_ok = compatible_bucket_duration(&file_index, bucket_duration);
394
395    if fresh && bucket_ok {
396        partition.stats.cache_hits += 1;
397        partition.responses.push((key, file_index));
398        return;
399    }
400
401    if !fresh {
402        partition.stats.stale_entries += 1;
403    }
404    if !bucket_ok {
405        partition.stats.incompatible_bucket += 1;
406    }
407    partition.keys_to_compute.push(key);
408}
409
410fn compatible_bucket_duration(file_index: &FileIndex, bucket_duration: Seconds) -> bool {
411    file_index.bucket_duration() <= bucket_duration
412        && bucket_duration.is_multiple_of(file_index.bucket_duration())
413}
414
415async fn compute_missing_indexes(
416    keys_to_compute: Vec<FileIndexKey>,
417    bucket_duration: Seconds,
418    cancellation: CancellationToken,
419    indexing_limits: IndexingLimits,
420    progress_counter: Option<Arc<AtomicUsize>>,
421) -> Result<Vec<(FileIndexKey, Result<FileIndex>)>> {
422    let compute_threads = compute_thread_count(keys_to_compute.len());
423    let cancellation_for_select = cancellation.clone();
424    let compute_task = tokio::task::spawn_blocking(move || {
425        compute_missing_indexes_blocking(
426            keys_to_compute,
427            bucket_duration,
428            cancellation,
429            indexing_limits,
430            progress_counter,
431            compute_threads,
432        )
433    });
434
435    tokio::select! {
436        result = compute_task => match result {
437            Ok(result) => result,
438            Err(e) => Err(EngineError::Io(std::io::Error::other(format!(
439                "Blocking task panicked: {}",
440                e
441            )))),
442        },
443        _ = cancellation_for_select.cancelled() => Err(EngineError::Cancelled),
444    }
445}
446
447fn compute_thread_count(key_count: usize) -> usize {
448    key_count.max(1).min(
449        std::thread::available_parallelism()
450            .map(|value| value.get())
451            .unwrap_or(1)
452            .min(MAX_BATCH_INDEX_THREADS),
453    )
454}
455
456fn compute_missing_indexes_blocking(
457    keys_to_compute: Vec<FileIndexKey>,
458    bucket_duration: Seconds,
459    cancellation: CancellationToken,
460    indexing_limits: IndexingLimits,
461    progress_counter: Option<Arc<AtomicUsize>>,
462    compute_threads: usize,
463) -> Result<Vec<(FileIndexKey, Result<FileIndex>)>> {
464    use rayon::prelude::*;
465    use std::sync::Arc;
466    use std::sync::atomic::AtomicBool;
467
468    let cancelled = Arc::new(AtomicBool::new(false));
469    let thread_pool = build_index_thread_pool(compute_threads)?;
470
471    Ok(thread_pool.install(|| {
472        keys_to_compute
473            .into_par_iter()
474            .map(|key| {
475                compute_one_index(
476                    key,
477                    bucket_duration,
478                    &cancellation,
479                    indexing_limits,
480                    progress_counter.as_ref(),
481                    &cancelled,
482                )
483            })
484            .collect::<Vec<(FileIndexKey, Result<FileIndex>)>>()
485    }))
486}
487
488fn build_index_thread_pool(compute_threads: usize) -> Result<rayon::ThreadPool> {
489    // Build a bounded local pool per call instead of using Rayon’s global pool.
490    // The global pool previously stayed alive after rebuild/indexing and kept a
491    // full worker set plus allocator arenas resident in the plugin process.
492    rayon::ThreadPoolBuilder::new()
493        .num_threads(compute_threads)
494        .build()
495        .map_err(|err| {
496            EngineError::Io(std::io::Error::other(format!(
497                "failed to build rayon index pool: {}",
498                err
499            )))
500        })
501}
502
503fn compute_one_index(
504    key: FileIndexKey,
505    bucket_duration: Seconds,
506    cancellation: &CancellationToken,
507    indexing_limits: IndexingLimits,
508    progress_counter: Option<&Arc<AtomicUsize>>,
509    cancelled: &std::sync::atomic::AtomicBool,
510) -> (FileIndexKey, Result<FileIndex>) {
511    if cancellation.is_cancelled() || cancelled.load(std::sync::atomic::Ordering::Relaxed) {
512        cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
513        return (key, Err(EngineError::Cancelled));
514    }
515
516    let result = index_one_file(&key, bucket_duration, indexing_limits);
517    if result.is_ok()
518        && let Some(counter) = progress_counter
519    {
520        counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
521    }
522
523    (key, result)
524}
525
526fn index_one_file(
527    key: &FileIndexKey,
528    bucket_duration: Seconds,
529    indexing_limits: IndexingLimits,
530) -> Result<FileIndex> {
531    FileIndexer::new(indexing_limits)
532        .index(
533            &key.file,
534            key.source_timestamp_field.as_ref(),
535            key.facets.as_slice(),
536            bucket_duration,
537        )
538        .map_err(|e| e.into())
539}
540
541fn store_computed_indexes(
542    registry: &Registry,
543    cache: &FileIndexCache,
544    responses: &mut Vec<(FileIndexKey, FileIndex)>,
545    computed_results: Vec<(FileIndexKey, Result<FileIndex>)>,
546) {
547    for (key, response) in computed_results {
548        match response {
549            Ok(index) => {
550                update_registry_time_range(registry, &key, &index);
551                cache.insert(key.clone(), index.clone());
552                responses.push((key, index));
553            }
554            Err(e) => {
555                error!(
556                    "file index computation failed for file={}: {}",
557                    key.file.path(),
558                    e
559                );
560            }
561        }
562    }
563}
564
565fn update_registry_time_range(registry: &Registry, key: &FileIndexKey, index: &FileIndex) {
566    registry.update_time_range(
567        &key.file,
568        index.start_time(),
569        index.end_time(),
570        index.indexed_at(),
571        index.online(),
572    );
573}