1use 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
22pub 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 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 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 pub fn with_cache_namespace(mut self, namespace: impl Into<String>) -> Self {
68 self.cache_namespace = namespace.into();
69 self
70 }
71
72 pub fn cache_namespace(&self) -> &str {
74 &self.cache_namespace
75 }
76
77 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 pub fn with_memory_capacity(mut self, capacity: usize) -> Self {
94 self.memory_capacity = Some(capacity);
95 self
96 }
97
98 pub fn with_disk_capacity(mut self, capacity: usize) -> Self {
100 self.disk_capacity = Some(capacity);
101 self
102 }
103
104 pub fn with_block_size(mut self, size: usize) -> Self {
106 self.block_size = Some(size);
107 self
108 }
109
110 pub fn without_disk_cache(mut self) -> Self {
112 self.enable_disk_cache = false;
113 self
114 }
115
116 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 .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
247pub 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 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}