1use std::sync::Arc;
35
36use async_trait::async_trait;
37use ulid::Ulid;
38
39use lunaris_core::circuit_breaker::CircuitBreaker;
40use lunaris_core::{ExtractError, LunarisError, StorageError};
41
42use crate::types::ChunkInput;
43use crate::{Extractor, RawExtractionBatch};
44
45#[derive(Clone, Debug)]
49pub struct ProviderId(String);
50
51impl ProviderId {
52 #[must_use]
53 pub fn new(s: impl Into<String>) -> Self {
54 Self(s.into())
55 }
56
57 #[must_use]
58 pub fn as_str(&self) -> &str {
59 &self.0
60 }
61}
62
63pub struct FallbackExtractor<P, F>
68where
69 P: Extractor,
70 F: Extractor,
71{
72 primary: P,
73 fallback: F,
74 breaker: Arc<CircuitBreaker>,
75 provider_id: ProviderId,
76}
77
78impl<P, F> FallbackExtractor<P, F>
79where
80 P: Extractor,
81 F: Extractor,
82{
83 pub fn new(primary: P, fallback: F, provider_id: ProviderId) -> Self {
85 Self { primary, fallback, breaker: Arc::new(CircuitBreaker::new()), provider_id }
86 }
87
88 #[must_use]
92 pub fn with_breaker(mut self, breaker: Arc<CircuitBreaker>) -> Self {
93 self.breaker = breaker;
94 self
95 }
96
97 #[must_use]
100 pub fn breaker(&self) -> &Arc<CircuitBreaker> {
101 &self.breaker
102 }
103
104 #[must_use]
105 pub fn provider_id(&self) -> &ProviderId {
106 &self.provider_id
107 }
108}
109
110#[async_trait]
111impl<P, F> Extractor for FallbackExtractor<P, F>
112where
113 P: Extractor,
114 F: Extractor,
115{
116 async fn extract(
117 &self,
118 episode_id: Ulid,
119 chunks: &[ChunkInput],
120 ) -> Result<RawExtractionBatch, LunarisError> {
121 if self.breaker.allow_request() {
122 match self.primary.extract(episode_id, chunks).await {
123 Ok(batch) => {
124 self.breaker.on_success();
125 tracing::trace!(
126 provider = %self.provider_id.as_str(),
127 "fallback.primary.success"
128 );
129 return Ok(batch);
130 }
131 Err(e) if is_transient(&e) => {
132 self.breaker.on_failure();
133 tracing::warn!(
134 provider = %self.provider_id.as_str(),
135 error = %e,
136 "fallback.primary.transient_failure — routing to fallback"
137 );
138 }
140 Err(e) => {
141 tracing::warn!(
143 provider = %self.provider_id.as_str(),
144 error = %e,
145 "fallback.primary.terminal_failure — not retrying"
146 );
147 return Err(e);
148 }
149 }
150 } else {
151 tracing::debug!(
152 provider = %self.provider_id.as_str(),
153 "fallback.primary.tripped — breaker open"
154 );
155 }
156
157 self.fallback.extract(episode_id, chunks).await
158 }
159
160 fn applies(&self) -> bool {
161 self.primary.applies() || self.fallback.applies()
162 }
163}
164
165#[must_use]
169pub fn is_transient(err: &LunarisError) -> bool {
170 match err {
171 LunarisError::Storage(StorageError::Backend(_)) => true,
172 LunarisError::Storage(StorageError::NotSupported(_)) => false,
173 LunarisError::Storage(_) => false,
174 LunarisError::Extract(ExtractError::Timeout) => true,
175 LunarisError::Extract(ExtractError::Backend(_)) => true,
176 LunarisError::Extract(ExtractError::GrammarReject(_)) => false,
177 LunarisError::Validate(_) => false,
178 LunarisError::Retrieve(_) => false,
179 LunarisError::Consolidate(_) => false,
180 _ => false,
184 }
185}
186
187#[must_use]
196pub fn fallback_wrap<P: Extractor>(primary: P, provider: &str) -> Arc<dyn Extractor> {
197 Arc::new(FallbackExtractor::new(primary, crate::NoopExtractor, ProviderId::new(provider)))
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use std::sync::Mutex;
204 use std::sync::atomic::{AtomicUsize, Ordering};
205
206 struct ScriptedExtractor {
209 results: Mutex<std::collections::VecDeque<Result<RawExtractionBatch, LunarisError>>>,
210 calls: AtomicUsize,
211 applies: bool,
212 }
213 impl ScriptedExtractor {
214 fn new(results: Vec<Result<RawExtractionBatch, LunarisError>>) -> Self {
215 Self { results: Mutex::new(results.into()), calls: AtomicUsize::new(0), applies: true }
216 }
217 fn calls(&self) -> usize {
218 self.calls.load(Ordering::Relaxed)
219 }
220 }
221 #[async_trait]
222 impl Extractor for ScriptedExtractor {
223 async fn extract(
224 &self,
225 _episode_id: Ulid,
226 _chunks: &[ChunkInput],
227 ) -> Result<RawExtractionBatch, LunarisError> {
228 self.calls.fetch_add(1, Ordering::Relaxed);
229 self.results
230 .lock()
231 .unwrap()
232 .pop_front()
233 .unwrap_or_else(|| Ok(RawExtractionBatch::default()))
234 }
235 fn applies(&self) -> bool {
236 self.applies
237 }
238 }
239
240 fn ok_batch() -> Result<RawExtractionBatch, LunarisError> {
241 Ok(RawExtractionBatch::default())
242 }
243 fn transient_err() -> Result<RawExtractionBatch, LunarisError> {
244 Err(LunarisError::Storage(StorageError::Backend("upstream 503".into())))
245 }
246 fn terminal_err() -> Result<RawExtractionBatch, LunarisError> {
247 Err(LunarisError::Extract(ExtractError::GrammarReject("malformed".into())))
248 }
249
250 #[tokio::test]
251 async fn primary_success_skips_fallback() {
252 let primary = ScriptedExtractor::new(vec![ok_batch()]);
253 let fallback = ScriptedExtractor::new(vec![ok_batch()]);
254 let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
255 let _ = f.extract(Ulid::new(), &[]).await.unwrap();
256 assert_eq!(f.primary.calls(), 1);
257 assert_eq!(f.fallback.calls(), 0, "fallback must not run on primary success");
258 }
259
260 #[tokio::test]
261 async fn transient_failure_falls_through_to_fallback() {
262 let primary = ScriptedExtractor::new(vec![transient_err()]);
263 let fallback = ScriptedExtractor::new(vec![ok_batch()]);
264 let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
265 let _ = f.extract(Ulid::new(), &[]).await.unwrap();
266 assert_eq!(f.primary.calls(), 1);
267 assert_eq!(f.fallback.calls(), 1, "fallback must run on transient primary failure");
268 }
269
270 #[tokio::test]
271 async fn terminal_failure_propagates_without_fallback() {
272 let primary = ScriptedExtractor::new(vec![terminal_err()]);
273 let fallback = ScriptedExtractor::new(vec![ok_batch()]);
274 let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
275 let err = f.extract(Ulid::new(), &[]).await.unwrap_err();
276 assert!(matches!(err, LunarisError::Extract(ExtractError::GrammarReject(_))));
277 assert_eq!(f.primary.calls(), 1);
278 assert_eq!(f.fallback.calls(), 0, "terminal failure must NOT mask via fallback");
279 }
280
281 #[tokio::test]
282 async fn breaker_trips_after_threshold() {
283 let primary_calls = vec![
286 transient_err(),
287 transient_err(),
288 transient_err(),
289 transient_err(),
290 transient_err(),
291 ok_batch(),
293 ];
294 let fallback_calls: Vec<_> = (0..6).map(|_| ok_batch()).collect();
295 let primary = ScriptedExtractor::new(primary_calls);
296 let fallback = ScriptedExtractor::new(fallback_calls);
297 let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
298
299 for _ in 0..5 {
300 let _ = f.extract(Ulid::new(), &[]).await.unwrap();
301 }
302 assert_eq!(f.primary.calls(), 5);
303 assert_eq!(f.fallback.calls(), 5);
304
305 let _ = f.extract(Ulid::new(), &[]).await.unwrap();
307 assert_eq!(f.primary.calls(), 5, "primary skipped while breaker Open");
308 assert_eq!(f.fallback.calls(), 6);
309 }
310
311 #[test]
312 fn is_transient_classifier() {
313 assert!(is_transient(&LunarisError::Storage(StorageError::Backend("x".into()))));
314 assert!(is_transient(&LunarisError::Extract(ExtractError::Timeout)));
315 assert!(is_transient(&LunarisError::Extract(ExtractError::Backend("x".into()))));
316 assert!(!is_transient(&LunarisError::Extract(ExtractError::GrammarReject("x".into()))));
317 assert!(!is_transient(&LunarisError::Storage(StorageError::NotSupported("x"))));
318 }
319
320 #[tokio::test]
321 async fn applies_is_true_if_either_arm_applies() {
322 let mut primary = ScriptedExtractor::new(vec![]);
323 primary.applies = false;
324 let fallback = ScriptedExtractor::new(vec![]);
325 let f = FallbackExtractor::new(primary, fallback, ProviderId::new("test"));
326 assert!(f.applies());
327
328 let mut primary2 = ScriptedExtractor::new(vec![]);
329 primary2.applies = false;
330 let mut fallback2 = ScriptedExtractor::new(vec![]);
331 fallback2.applies = false;
332 let f2 = FallbackExtractor::new(primary2, fallback2, ProviderId::new("test"));
333 assert!(!f2.applies());
334 }
335
336 #[tokio::test]
343 async fn fallback_wrap_transient_degrades_to_noop() {
344 let wrapped = fallback_wrap(ScriptedExtractor::new(vec![transient_err()]), "gemma-3-4b-it");
345 let chunks = vec![
346 ChunkInput {
347 chunk_id: Ulid::new(),
348 text: "a".into(),
349 heading_path: vec![],
350 reference_time_iso: None,
351 },
352 ChunkInput {
353 chunk_id: Ulid::new(),
354 text: "b".into(),
355 heading_path: vec![],
356 reference_time_iso: None,
357 },
358 ];
359 let batch = wrapped
360 .extract(Ulid::new(), &chunks)
361 .await
362 .expect("transient primary must degrade to the NoopExtractor floor, not error");
363 assert_eq!(batch.by_chunk.len(), 2, "NoopExtractor emits one empty extraction per chunk");
364 assert!(
365 batch
366 .by_chunk
367 .iter()
368 .all(|r| r.entities.is_empty() && r.relations.is_empty() && r.facts.is_empty()),
369 "the fallback floor produces empty extractions"
370 );
371 }
372
373 #[tokio::test]
374 async fn fallback_wrap_terminal_propagates() {
375 let wrapped = fallback_wrap(ScriptedExtractor::new(vec![terminal_err()]), "gemma-3-4b-it");
376 let err = wrapped
377 .extract(Ulid::new(), &[])
378 .await
379 .expect_err("terminal primary error must propagate, not fall back to Noop");
380 assert!(
381 matches!(err, LunarisError::Extract(ExtractError::GrammarReject(_))),
382 "terminal error propagates unchanged, got {err:?}"
383 );
384 }
385}