use super::error::{OpenSearchError, Result};
use super::field_mappings::FieldMappings;
use moka::future::Cache;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
pub const DEFAULT_TTL: Duration = Duration::from_secs(300);
pub const DEFAULT_NEGATIVE_TTL: Duration = Duration::from_secs(10);
pub const DEFAULT_MAX_ENTRIES: u64 = 512;
#[derive(Clone)]
pub struct MappingCache {
resolved: Cache<String, Arc<FieldMappings>>,
failures: Cache<String, Arc<str>>,
}
impl MappingCache {
pub fn new() -> Self {
Self::with_config(DEFAULT_TTL, DEFAULT_NEGATIVE_TTL, DEFAULT_MAX_ENTRIES)
}
pub fn with_config(ttl: Duration, negative_ttl: Duration, max_entries: u64) -> Self {
Self {
resolved: Cache::builder()
.max_capacity(max_entries)
.time_to_live(ttl)
.build(),
failures: Cache::builder()
.max_capacity(max_entries)
.time_to_live(negative_ttl)
.build(),
}
}
pub async fn get_or_fetch<F, Fut>(
&self,
index_pattern: &str,
fetch: F,
) -> Result<Arc<FieldMappings>>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<FieldMappings>>,
{
if let Some(reason) = self.failures.get(index_pattern).await {
return Err(OpenSearchError::MappingError(reason.to_string()));
}
let result = self
.resolved
.try_get_with_by_ref(index_pattern, async move { fetch().await.map(Arc::new) })
.await;
match result {
Ok(mappings) => Ok(mappings),
Err(arc_err) => {
let reason = arc_err.to_string();
self.failures
.insert(index_pattern.to_string(), Arc::from(reason.as_str()))
.await;
Err(OpenSearchError::MappingError(reason))
}
}
}
pub async fn invalidate(&self, index_pattern: &str) {
self.resolved.invalidate(index_pattern).await;
self.failures.invalidate(index_pattern).await;
}
pub async fn resolved_len(&self) -> u64 {
self.resolved.run_pending_tasks().await;
self.resolved.entry_count()
}
pub async fn failure_len(&self) -> u64 {
self.failures.run_pending_tasks().await;
self.failures.entry_count()
}
}
impl Default for MappingCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::opensearch::field_mappings::{FieldMapping, FieldType};
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
fn mappings_with(field: &str) -> FieldMappings {
let mut m = FieldMappings::new();
m.add_mapping(
field.to_string(),
FieldMapping {
field_type: FieldType::Keyword,
subfields: HashMap::new(),
},
);
m
}
#[tokio::test]
async fn a_hit_does_not_refetch() {
let cache = MappingCache::new();
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..3 {
let calls = Arc::clone(&calls);
let got = cache
.get_or_fetch("logs-*", || async move {
calls.fetch_add(1, Ordering::SeqCst);
Ok(mappings_with("host.name"))
})
.await
.unwrap();
assert_eq!(got.len(), 1);
}
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"cached entry must not refetch"
);
}
#[tokio::test]
async fn distinct_patterns_are_distinct_entries() {
let cache = MappingCache::new();
let calls = Arc::new(AtomicUsize::new(0));
for pattern in ["logs-a", "logs-b"] {
let calls = Arc::clone(&calls);
cache
.get_or_fetch(pattern, || async move {
calls.fetch_add(1, Ordering::SeqCst);
Ok(mappings_with("f"))
})
.await
.unwrap();
}
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn a_failure_is_remembered_and_not_retried() {
let cache = MappingCache::new();
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..3 {
let calls = Arc::clone(&calls);
let err = cache
.get_or_fetch("logs-*", || async move {
calls.fetch_add(1, Ordering::SeqCst);
Err(OpenSearchError::MappingError("cluster unreachable".into()))
})
.await
.unwrap_err();
assert!(err.to_string().contains("cluster unreachable"));
}
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"a cached failure must not refetch"
);
assert_eq!(cache.failure_len().await, 1);
assert_eq!(
cache.resolved_len().await,
0,
"a failure must never land in the resolved map, where it would read as empty mappings"
);
}
#[tokio::test]
async fn a_failure_expires_and_then_recovers() {
let cache = MappingCache::with_config(
Duration::from_secs(300),
Duration::from_millis(50),
DEFAULT_MAX_ENTRIES,
);
let err = cache
.get_or_fetch("logs-*", || async {
Err(OpenSearchError::MappingError("down".into()))
})
.await;
assert!(err.is_err());
tokio::time::sleep(Duration::from_millis(120)).await;
let ok = cache
.get_or_fetch("logs-*", || async { Ok(mappings_with("host.name")) })
.await;
assert!(
ok.is_ok(),
"the negative entry must expire so the cluster is retried"
);
}
#[tokio::test]
async fn a_resolved_entry_expires() {
let cache = MappingCache::with_config(
Duration::from_millis(50),
DEFAULT_NEGATIVE_TTL,
DEFAULT_MAX_ENTRIES,
);
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let calls = Arc::clone(&calls);
cache
.get_or_fetch("logs-*", || async move {
calls.fetch_add(1, Ordering::SeqCst);
Ok(mappings_with("f"))
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(80)).await;
}
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"a wildcard's index set moves; the entry has to expire"
);
}
#[tokio::test]
async fn concurrent_callers_share_one_fetch() {
let cache = MappingCache::new();
let calls = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..16 {
let cache = cache.clone();
let calls = Arc::clone(&calls);
handles.push(tokio::spawn(async move {
cache
.get_or_fetch("logs-*", || async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(30)).await;
Ok(mappings_with("host.name"))
})
.await
.map(|m| m.len())
}));
}
for h in handles {
assert_eq!(h.await.unwrap().unwrap(), 1);
}
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"16 concurrent searches on one pattern must cost one _field_caps call"
);
}
#[tokio::test]
async fn invalidate_forces_a_refetch() {
let cache = MappingCache::new();
let calls = Arc::new(AtomicUsize::new(0));
for _ in 0..2 {
let calls = Arc::clone(&calls);
cache
.get_or_fetch("logs-*", || async move {
calls.fetch_add(1, Ordering::SeqCst);
Ok(mappings_with("f"))
})
.await
.unwrap();
cache.invalidate("logs-*").await;
}
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn invalidate_clears_a_remembered_failure() {
let cache = MappingCache::new();
let _ = cache
.get_or_fetch("logs-*", || async {
Err(OpenSearchError::MappingError("down".into()))
})
.await;
cache.invalidate("logs-*").await;
let ok = cache
.get_or_fetch("logs-*", || async { Ok(mappings_with("f")) })
.await;
assert!(
ok.is_ok(),
"the miss-triggered refresh has to pierce the negative entry too, \
or the refresh is wasted"
);
}
}