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::{
132 BlockEngineBuilder, DeviceBuilder, FsDeviceBuilder, IoEngineBuilder,
133 PsyncIoEngineBuilder,
134 };
135
136 let cache_path = self
137 .cache_path
138 .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
250pub 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 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}