1use std::future::Future;
16use std::sync::Arc;
17use std::time::Duration;
18
19use common_frontend::metrics;
20use datatypes::timestamp::TimestampNanosecond;
21use moka::future::Cache;
22
23use crate::error::{CacheLoadSnafu, MultiPipelineWithDiffSchemaSnafu, Result};
24use crate::etl::Pipeline;
25use crate::manager::PipelineVersion;
26use crate::table::EMPTY_SCHEMA_NAME;
27use crate::util::{generate_pipeline_cache_key, generate_pipeline_cache_key_suffix};
28
29const PIPELINES_CACHE_SIZE: u64 = 10000;
31
32pub(crate) struct PipelineCache {
40 pipelines: Cache<String, Arc<Pipeline>>,
41 original_pipelines: Cache<String, PipelineContent>,
42 failover_cache: Cache<String, PipelineContent>,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct PipelineContent {
49 pub name: String,
50 pub content: String,
51 pub version: TimestampNanosecond,
52 pub schema: String,
53}
54
55impl PipelineCache {
56 pub(crate) fn new(ttl: Duration) -> Self {
57 Self {
58 pipelines: Cache::builder()
59 .max_capacity(PIPELINES_CACHE_SIZE)
60 .time_to_live(ttl)
61 .name("pipelines")
62 .build(),
63 original_pipelines: Cache::builder()
64 .max_capacity(PIPELINES_CACHE_SIZE)
65 .time_to_live(ttl)
66 .name("original_pipelines")
67 .build(),
68 failover_cache: Cache::builder()
69 .max_capacity(PIPELINES_CACHE_SIZE)
70 .name("failover_cache")
71 .build(),
72 }
73 }
74
75 pub(crate) async fn get_pipeline_with(
77 &self,
78 schema: &str,
79 name: &str,
80 version: PipelineVersion,
81 init: impl Future<Output = Result<Arc<Pipeline>>>,
82 ) -> Result<Arc<Pipeline>> {
83 let key = generate_pipeline_cache_key(schema, name, version);
84 let entry = self.pipelines.entry(key).or_try_insert_with(init).await;
85 metrics::record_cache_lookup(
86 "pipeline",
87 entry.as_ref().is_ok_and(|entry| !entry.is_fresh()),
88 );
89 entry
90 .map(|entry| entry.into_value())
91 .map_err(|error| CacheLoadSnafu { error }.build())
92 }
93
94 pub(crate) async fn get_pipeline_str_with(
96 &self,
97 schema: &str,
98 name: &str,
99 version: PipelineVersion,
100 init: impl Future<Output = Result<PipelineContent>>,
101 ) -> Result<PipelineContent> {
102 let key = generate_pipeline_cache_key(schema, name, version);
103 let entry = self
104 .original_pipelines
105 .entry(key)
106 .or_try_insert_with(init)
107 .await;
108 metrics::record_cache_lookup(
109 "pipeline_original",
110 entry.as_ref().is_ok_and(|entry| !entry.is_fresh()),
111 );
112 entry
113 .map(|entry| entry.into_value())
114 .map_err(|error| CacheLoadSnafu { error }.build())
115 }
116
117 pub(crate) async fn get_failover_cache(
120 &self,
121 schema: &str,
122 name: &str,
123 version: PipelineVersion,
124 ) -> Result<Option<PipelineContent>> {
125 for key in [
126 generate_pipeline_cache_key(EMPTY_SCHEMA_NAME, name, version),
127 generate_pipeline_cache_key(schema, name, version),
128 ] {
129 if let Some(content) = self.failover_cache.get(&key).await {
130 metrics::record_cache_lookup("pipeline_failover", true);
131 return Ok(Some(content));
132 }
133 }
134
135 let suffix = generate_pipeline_cache_key_suffix(name, version);
137 let mut found = self
138 .failover_cache
139 .iter()
140 .filter(|(k, _)| k.ends_with(&suffix))
141 .collect::<Vec<_>>();
142
143 let result = match found.len() {
144 0 => Ok(None),
145 1 => Ok(Some(found.remove(0).1)),
146 _ => MultiPipelineWithDiffSchemaSnafu {
147 name: name.to_string(),
148 current_schema: schema.to_string(),
149 schemas: found
150 .iter()
151 .filter_map(|(k, _)| k.split_once('/').map(|k| k.0))
152 .collect::<Vec<_>>()
153 .join(","),
154 }
155 .fail(),
156 };
157 metrics::record_cache_lookup(
158 "pipeline_failover",
159 result.as_ref().is_ok_and(|content| content.is_some()),
160 );
161 result
162 }
163
164 pub(crate) async fn insert_failover_cache(&self, content: PipelineContent, with_latest: bool) {
165 let versioned =
166 generate_pipeline_cache_key(&content.schema, &content.name, Some(content.version));
167 let latest = generate_pipeline_cache_key(&content.schema, &content.name, None);
168
169 self.failover_cache.insert(versioned, content.clone()).await;
170 if with_latest {
171 self.failover_cache.insert(latest, content).await;
172 }
173 }
174
175 pub(crate) async fn on_pipeline_created(&self, content: PipelineContent) {
179 self.invalidate(&content.name, None).await;
180 self.insert_failover_cache(content, true).await;
181 }
182
183 pub(crate) async fn invalidate(&self, name: &str, version: PipelineVersion) {
186 let mut suffixes = vec![generate_pipeline_cache_key_suffix(name, None)];
187 if version.is_some() {
188 suffixes.push(generate_pipeline_cache_key_suffix(name, version));
189 }
190
191 let ks = self
192 .pipelines
193 .iter()
194 .map(|(k, _)| k)
195 .chain(self.original_pipelines.iter().map(|(k, _)| k))
196 .chain(self.failover_cache.iter().map(|(k, _)| k))
197 .filter(|k| suffixes.iter().any(|suffix| k.ends_with(suffix)))
198 .collect::<Vec<_>>();
199
200 for k in ks {
201 let k = k.as_str();
202 self.pipelines.invalidate(k).await;
203 self.original_pipelines.invalidate(k).await;
204 self.failover_cache.invalidate(k).await;
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use std::sync::atomic::{AtomicUsize, Ordering};
212
213 use common_frontend::metrics::{CACHE_HIT, CACHE_MISS};
214 use tokio::sync::Notify;
215
216 use super::*;
217
218 fn content_at(version: i64) -> PipelineContent {
220 PipelineContent {
221 name: "p".to_string(),
222 content: "transform:".to_string(),
223 version: TimestampNanosecond::new(version),
224 schema: EMPTY_SCHEMA_NAME.to_string(),
225 }
226 }
227
228 #[tokio::test]
229 async fn test_concurrent_misses_run_one_loader() {
230 const CONCURRENCY: usize = 8;
231
232 let hits = CACHE_HIT.with_label_values(&["pipeline_original"]);
233 let misses = CACHE_MISS.with_label_values(&["pipeline_original"]);
234 let before = (hits.get(), misses.get());
235 let cache = PipelineCache::new(Duration::from_secs(60));
236 let loads = AtomicUsize::new(0);
237 let release = Notify::new();
238 let mut requests = (0..CONCURRENCY)
239 .map(|_| {
240 Box::pin(cache.get_pipeline_str_with("db", "p", None, async {
241 loads.fetch_add(1, Ordering::SeqCst);
242 release.notified().await;
243 Ok(content_at(1))
244 }))
245 })
246 .collect::<Vec<_>>();
247 for request in &mut requests {
248 assert!(futures::poll!(request.as_mut()).is_pending());
249 }
250 assert_eq!(loads.load(Ordering::SeqCst), 1);
251 release.notify_waiters();
252 for request in requests {
253 assert_eq!(request.await.unwrap(), content_at(1));
254 }
255 assert_eq!(loads.load(Ordering::SeqCst), 1);
256 assert_eq!(
257 (hits.get(), misses.get()),
258 (before.0 + CONCURRENCY as u64 - 1, before.1 + 1)
259 );
260 }
261
262 #[tokio::test]
263 async fn test_pipeline_lookup_metrics() {
264 let cache = PipelineCache::new(Duration::from_secs(60));
265 for compiled in [false, true] {
266 let cache_type = if compiled {
267 "pipeline"
268 } else {
269 "pipeline_original"
270 };
271 let hits = CACHE_HIT.with_label_values(&[cache_type]);
272 let misses = CACHE_MISS.with_label_values(&[cache_type]);
273 let before = (hits.get(), misses.get());
274 let load = async |fail| {
275 if compiled {
276 cache
277 .get_pipeline_with("db", "p", None, async {
278 if fail {
279 return Err(crate::error::PipelineNotFoundSnafu {
280 name: "p",
281 version: None,
282 }
283 .build());
284 }
285 Ok(Arc::new(crate::parse(&crate::Content::Yaml(
286 "transform:\n - fields: [message]\n type: string",
287 ))?))
288 })
289 .await
290 .map(|_| ())
291 } else {
292 cache
293 .get_pipeline_str_with("db", "p", None, async {
294 if fail {
295 return Err(crate::error::PipelineNotFoundSnafu {
296 name: "p",
297 version: None,
298 }
299 .build());
300 }
301 Ok(content_at(1))
302 })
303 .await
304 .map(|_| ())
305 }
306 };
307 assert!(load(true).await.is_err());
308 load(false).await.unwrap();
309 load(true).await.unwrap();
311 assert_eq!((hits.get(), misses.get()), (before.0 + 1, before.1 + 2));
312 }
313 }
314
315 #[tokio::test]
316 async fn test_failover_lookup_metrics() {
317 let hits = CACHE_HIT.with_label_values(&["pipeline_failover"]);
318 let misses = CACHE_MISS.with_label_values(&["pipeline_failover"]);
319 let before = (hits.get(), misses.get());
320 let cache = PipelineCache::new(Duration::from_secs(60));
321 assert!(
322 cache
323 .get_failover_cache("db", "p", None)
324 .await
325 .unwrap()
326 .is_none()
327 );
328 let local = PipelineContent {
329 schema: "a".into(),
330 ..content_at(1)
331 };
332 cache.insert_failover_cache(local.clone(), true).await;
333 assert_eq!(
335 cache.get_failover_cache("a", "p", None).await.unwrap(),
336 Some(local.clone())
337 );
338 assert_eq!(
339 cache.get_failover_cache("db", "p", None).await.unwrap(),
340 Some(local)
341 );
342 cache
343 .insert_failover_cache(
344 PipelineContent {
345 schema: "b".into(),
346 ..content_at(2)
347 },
348 true,
349 )
350 .await;
351 assert!(cache.get_failover_cache("db", "p", None).await.is_err());
352 for (schema, version) in [(EMPTY_SCHEMA_NAME, 3), ("c", 4)] {
354 cache
355 .insert_failover_cache(
356 PipelineContent {
357 schema: schema.into(),
358 ..content_at(version)
359 },
360 true,
361 )
362 .await;
363 assert_eq!(
364 cache.get_failover_cache("db", "p", None).await.unwrap(),
365 Some(content_at(3))
366 );
367 }
368 assert_eq!((hits.get(), misses.get()), (before.0 + 4, before.1 + 2));
369 }
370
371 #[tokio::test]
372 async fn test_delete_drops_version_pinned_entry() {
373 let cache = PipelineCache::new(Duration::from_secs(60));
374 let content = content_at(1);
375 let version = Some(content.version);
376
377 cache
378 .get_pipeline_str_with("db", "p", version, async { Ok(content.clone()) })
379 .await
380 .unwrap();
381
382 cache.invalidate("p", version).await;
383
384 let loads = AtomicUsize::new(0);
385 cache
386 .get_pipeline_str_with("db", "p", version, async {
387 loads.fetch_add(1, Ordering::SeqCst);
388 Ok(content.clone())
389 })
390 .await
391 .unwrap();
392 assert_eq!(loads.load(Ordering::SeqCst), 1);
393 }
394
395 #[tokio::test]
396 async fn test_create_drops_stale_latest_and_primes_failover() {
397 let cache = PipelineCache::new(Duration::from_secs(60));
398 let v2 = content_at(2);
399
400 cache
401 .get_pipeline_str_with("a", "p", None, async { Ok(content_at(1)) })
402 .await
403 .unwrap();
404 cache.insert_failover_cache(content_at(1), true).await;
405
406 cache.on_pipeline_created(v2.clone()).await;
407
408 let loads = AtomicUsize::new(0);
409 let cached = cache
410 .get_pipeline_str_with("a", "p", None, async {
411 loads.fetch_add(1, Ordering::SeqCst);
412 Ok(v2.clone())
413 })
414 .await
415 .unwrap();
416 assert_eq!(loads.load(Ordering::SeqCst), 1);
417 assert_eq!(cached.version, v2.version);
418
419 let failover = cache.get_failover_cache("b", "p", None).await.unwrap();
420 assert_eq!(failover.map(|c| c.version), Some(v2.version));
421 }
422}