Skip to main content

netgauze_yang_push/cache/
actor.rs

1// Copyright (C) 2025-present The NetGauze Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//    http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12// implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Actor-based concurrent interface for YANG Library cache operations.
17//!
18//! This module provides an asynchronous, message-passing interface to the YANG
19//! Library cache using the actor pattern. It enables concurrent access to the
20//! cache while automatically fetching missing YANG libraries from remote
21//! devices.
22//!
23//! # Overview
24//!
25//! The actor module wraps the synchronous [`YangLibraryCache`] from the
26//! [`storage`](super::storage) module with an actor-based interface that:
27//!
28//! - Provides thread-safe, concurrent access to the cache via message passing
29//! - Automatically fetches missing YANG libraries from remote devices on cache
30//!   miss
31//! - Deduplicates concurrent fetch requests for the same subscription
32//! - Manages background worker tasks for network operations
33//! - Broadcasts results to all waiting requesters
34//!
35//! # Architecture
36//!
37//! ```text
38//! ┌─────────────────────┐
39//! │ CacheActorHandle    │  ← Public API (cloneable, Send + Sync)
40//! │  - request_tx       │
41//! │  - cmd_tx           │
42//! └──────────┬──────────┘
43//!            │ (channels)
44//!            ▼
45//! ┌───────────────────────┐
46//! │ CacheActor            │  ← Private actor (single task)
47//! │  - YangLibraryCache   │     - Processes requests sequentially
48//! │  - YangLibraryFetcher │     - Manages worker queue
49//! │  - pending_requests   │     - Deduplicates fetch operations
50//! │  - workers_queue      │
51//! └───────────────────────┘
52//!            │
53//!            ▼
54//! ┌─────────────────────┐
55//! │ Worker Tasks        │  ← Background fetchers
56//! │  - Fetch from NETCONF
57//! │  - Parse YANG modules
58//! │  - Return results
59//! └─────────────────────┘
60//! ```
61//!
62//! # Key Components
63//!
64//! - [`CacheActorHandle`]: The public handle for interacting with the cache
65//!   actor. Provides methods to send lookup requests and shutdown the actor.
66//!   Can be cloned and shared across tasks.
67//!
68//! - `CacheActor`: The internal actor that processes cache requests. Runs in a
69//!   single async task and coordinates cache operations, network fetches, and
70//!   result distribution.
71//!
72//! - [`CacheLookupCommand`]: Messages sent to the actor for cache lookup
73//!   operations. Supports lookups by subscription info or content ID, with both
74//!   streaming and one-shot response modes.
75//!
76//! # Request Flow
77//!
78//! ## Cache Hit (Fast Path)
79//!
80//! ```text
81//! Client → Handle → Actor → Cache Lookup → Immediate Response
82//!          (async)  (async)  (in-memory)
83//! ```
84//!
85//! ## Cache Miss (Fetch Required)
86//!
87//! ```text
88//! Client 1 → Handle → Actor → Cache Miss → Start Fetch Worker
89//!                                             ↓
90//! Client 2 → Handle → Actor → Cache Miss → Queue Request (same subscription)
91//!                                             ↓
92//! Client 3 → Handle → Actor → Cache Miss → Queue Request (same subscription)
93//!                                             ↓
94//!                                        Worker Completes
95//!                                             ↓
96//!                                     Store in Cache
97//!                                             ↓
98//!                              Broadcast to Clients 1, 2, 3
99//! ```
100//!
101//! # Lookup Modes
102//!
103//! The actor supports two lookup modes:
104//!
105//! ## Streaming Mode
106//!
107//! Uses `async_channel::Sender` for response. Multiple requests can share the
108//! same sender, enabling pub-sub patterns:
109//!
110//! - `LookupBySubscriptionInfo`: Cache hit returns immediately; cache miss
111//!   triggers fetch and queues the request until the fetch completes.
112//! - `LookupByContentId`: Always returns immediately (no fetch on miss).
113//!
114//! ## One-Shot Mode
115//!
116//! Uses `oneshot::Sender` for response. Blocks until the result is available:
117//!
118//! - `LookupBySubscriptionInfoOneShot`: Blocks if cache misses, fetches
119//!   synchronously.
120//! - `LookupByContentIdOneShot`: Returns immediately (no fetch on miss).
121//!
122//! # Fetch Deduplication
123//!
124//! When multiple requests arrive for the same subscription before a fetch
125//! completes:
126//!
127//! 1. The first request triggers the fetch worker
128//! 2. Subsequent requests are queued in `pending_requests`
129//! 3. When fetch completes, all queued requesters receive the result
130//! 4. No redundant network operations occur
131//!
132//! This prevents "thundering herd" problems when many clients request the same
133//! uncached YANG library simultaneously.
134//!
135//! # Error Handling
136//!
137//! - **Cache errors**: Returned via [`CacheActorHandleError`]
138//! - **Fetch failures**: Logged but don't crash the actor; pending requesters
139//!   receive `None`
140//! - **Channel closures**: Actor shuts down gracefully
141//! - **Worker panics**: Logged as join errors; and the actor continues
142//!   processing
143//!
144//! # Shutdown Behavior
145//!
146//! When [`CacheActorHandle::shutdown`] is called:
147//!
148//! 1. All pending worker tasks are aborted
149//! 2. Pending requests are not fulfilled
150//! 3. Channels are closed (Requesters can detect this by checking is_closed())
151//! 4. Actor task exits cleanly
152//!
153//! The cache state on disk is preserved and can be reloaded on restart.
154//!
155//! # Example Usage
156//!
157//! ```rust,ignore
158//! use std::path::PathBuf;
159//! use crate::cache::actor::CacheActorHandle;
160//! use crate::cache::fetcher::NetconfYangLibraryFetcher;
161//!
162//! // Create the actor with a fetcher
163//! let fetcher = NetconfYangLibraryFetcher::new(/* ... */);
164//! let (join_handle, handle) = CacheActorHandle::new(
165//!     100,  // buffer size
166//!     either::Right(PathBuf::from("/var/lib/yang-cache")),
167//!     fetcher,
168//! )?;
169//!
170//! // Clone the handle for multiple tasks
171//! let handle1 = handle.clone();
172//! let handle2 = handle.clone();
173//!
174//! // Task 1: Lookup by subscription info (async)
175//! tokio::spawn(async move {
176//!     let (tx, rx) = async_channel::unbounded();
177//!     let subscription_info = /* ... */;
178//!     handle1.request_tx()
179//!         .send(CacheLookupCommand::LookupBySubscriptionInfo(subscription_info, tx))
180//!         .await?;
181//!
182//!     if let Some(yang_lib_ref) = rx.recv().await? {
183//!         let schemas = yang_lib_ref.load_schemas()?;
184//!         // Use schemas...
185//!     }
186//! });
187//!
188//! // Task 2: Lookup by content ID (one-shot)
189//! tokio::spawn(async move {
190//!     let (tx, rx) = oneshot::channel();
191//!     let content_id = "abc123".into();
192//!     handle2.request_tx()
193//!         .send(CacheLookupCommand::LookupByContentIdOneShot(content_id, tx))
194//!         .await?;
195//!
196//!     if let Some(yang_lib_ref) = rx.await? {
197//!         // Use yang_lib_ref...
198//!     }
199//! });
200//!
201//! // Shutdown when done
202//! handle.shutdown().await?;
203//! join_handle.await??;
204//! ```
205//!
206//! # Performance Considerations
207//!
208//! - **Cache hits**: Sub-millisecond latency (in-memory HashMap lookup)
209//! - **Cache misses**: Network-bound (depends on device response time and YANG
210//!   module count)
211//! - **Concurrent requests**: O(1) message passing overhead
212//! - **Fetch deduplication**: Prevents N × network_latency for N concurrent
213//!   requests
214
215use crate::cache::fetcher::{FetcherResult, YangLibraryFetcher};
216use crate::cache::storage::{
217    SubscriptionInfo, YangLibraryCache, YangLibraryCacheError, YangLibraryReference,
218};
219use crate::{
220    ContentId, OTL_YANG_PUSH_CACHED_CONTENT_ID_KEY, OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
221    OTL_YANG_PUSH_SUBSCRIPTION_ROUTER_CONTENT_ID_KEY, OTL_YANG_PUSH_SUBSCRIPTION_TARGET_KEY,
222};
223use futures_util::StreamExt;
224use futures_util::stream::FuturesUnordered;
225use netgauze_netconf_proto::yang_push::types::SubscriptionId;
226use rustc_hash::FxHashMap;
227use std::net::SocketAddr;
228use std::path::PathBuf;
229use std::sync::Arc;
230use std::time::Duration;
231use tokio::sync::{mpsc, oneshot};
232use tokio::task::{JoinError, JoinHandle};
233use tracing::{debug, info, warn};
234
235const OTL_CACHE_REQUEST_TYPE: &str = "netgauze.udp.notif.yang.push.cache.request.type";
236
237#[derive(Debug, Clone)]
238pub struct CachingStats {
239    pub requests_received: opentelemetry::metrics::Counter<u64>,
240    pub pending_cache_requests: opentelemetry::metrics::Gauge<u64>,
241    pub cache_hits: opentelemetry::metrics::Counter<u64>,
242    pub cache_misses: opentelemetry::metrics::Counter<u64>,
243    pub device_fetch_request: opentelemetry::metrics::Counter<u64>,
244    pub device_fetch_queue: opentelemetry::metrics::Gauge<u64>,
245    pub device_fetch_succeeded: opentelemetry::metrics::Counter<u64>,
246    pub device_fetch_failed: opentelemetry::metrics::Counter<u64>,
247}
248
249impl CachingStats {
250    pub fn new(meter: opentelemetry::metrics::Meter) -> Self {
251        let requests_received = meter
252            .u64_counter("netgauze.collector.yang_push.caching.requests.received")
253            .with_description("Number of requests received by the YANG library cache actor")
254            .build();
255        let pending_cache_requests = meter
256            .u64_gauge("netgauze.collector.yang_push.caching.requests.pending")
257            .with_description("Number of pending cache requests in the YANG library cache actor that are waiting for fetch to complete")
258            .build();
259        let cache_hits = meter
260            .u64_counter("netgauze.collector.yang_push.caching.requests.cache.hits")
261            .with_description("Number of cache hits in the YANG library cache actor")
262            .build();
263        let cache_misses = meter
264            .u64_counter("netgauze.collector.yang_push.caching.requests.cache.misses")
265            .with_description("Number of cache misses in the YANG library cache actor")
266            .build();
267        let device_fetch_request = meter
268            .u64_counter("netgauze.collector.yang_push.caching.device.fetch.requests")
269            .with_description(
270                "Number of device fetch requests initiated by the YANG library cache actor",
271            )
272            .build();
273        let device_fetch_queue = meter
274            .u64_gauge("netgauze.collector.yang_push.caching.device.fetch.pending")
275            .with_description("Number of device fetch requests that are currently queued in the YANG library cache actor")
276            .build();
277        let device_fetch_succeeded = meter
278            .u64_counter("netgauze.collector.yang_push.caching.device.fetch.response.succeeded")
279            .with_description("Number of device fetch requests initiated by the YANG library cache actor and succeeded")
280            .build();
281        let device_fetch_failed = meter
282            .u64_counter("netgauze.collector.yang_push.caching.device.fetch.response.failed")
283            .with_description("Number of device fetch requests initiated by the YANG library cache actor and failed")
284            .build();
285        Self {
286            requests_received,
287            pending_cache_requests,
288            cache_hits,
289            cache_misses,
290            device_fetch_request,
291            device_fetch_queue,
292            device_fetch_succeeded,
293            device_fetch_failed,
294        }
295    }
296}
297
298#[derive(Debug, Clone)]
299pub enum CacheActorCommand {
300    Shutdown,
301    /// Invoke periodic cleanup operations to prevent memory leaks.
302    Cleanup,
303}
304
305#[derive(Debug)]
306pub enum CacheLookupCommand {
307    LookupBySubscriptionInfo(SubscriptionInfo, async_channel::Sender<CacheResponse>),
308
309    LookupBySubscriptionInfoOneShot(SubscriptionInfo, oneshot::Sender<CacheResponse>),
310
311    LookupBySubscriptionId {
312        collector: SocketAddr,
313        interface: Option<String>,
314        peer: SocketAddr,
315        subscription_id: SubscriptionId,
316        tx: async_channel::Sender<CacheResponse>,
317    },
318
319    LookupBySubscriptionIdOneShot {
320        collector: SocketAddr,
321        interface: Option<String>,
322        peer: SocketAddr,
323        subscription_id: SubscriptionId,
324        tx: oneshot::Sender<CacheResponse>,
325    },
326
327    LookupByContentId(
328        ContentId,
329        async_channel::Sender<(ContentId, Option<Arc<YangLibraryReference>>)>,
330    ),
331
332    LookupByContentIdOneShot(
333        ContentId,
334        oneshot::Sender<(ContentId, Option<Arc<YangLibraryReference>>)>,
335    ),
336}
337
338impl std::fmt::Display for CacheLookupCommand {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        match self {
341            Self::LookupBySubscriptionInfo(subscription_info, _) => {
342                write!(f, "lookup by subscription info {subscription_info}")
343            }
344            Self::LookupBySubscriptionInfoOneShot(subscription_info, _) => {
345                write!(
346                    f,
347                    "lookup by subscription info {subscription_info} (one shot)"
348                )
349            }
350            Self::LookupBySubscriptionId {
351                collector,
352                interface,
353                peer,
354                subscription_id,
355                tx: _tx,
356            } => {
357                write!(
358                    f,
359                    "lookup by subscription id {subscription_id} from peer {peer}, collector {collector}, interface {interface:?}",
360                )
361            }
362            Self::LookupBySubscriptionIdOneShot {
363                collector,
364                interface,
365                peer,
366                subscription_id,
367                tx: _tx,
368            } => {
369                write!(
370                    f,
371                    "lookup by subscription id {subscription_id} from peer {peer}, collector {collector}, interface {interface:?} (one shot)",
372                )
373            }
374            Self::LookupByContentId(content_id, _) => {
375                write!(f, "lookup by content id {content_id}")
376            }
377            Self::LookupByContentIdOneShot(content_id, _) => {
378                write!(f, "lookup by content id {content_id} (one shot)")
379            }
380        }
381    }
382}
383
384#[derive(Debug, strum_macros::Display)]
385pub enum CacheActorCacheError {}
386
387impl std::error::Error for CacheActorCacheError {}
388
389#[derive(Debug, Clone)]
390pub struct CacheResponse {
391    cached_content_id: Option<ContentId>,
392    subscription_info: SubscriptionInfo,
393    yang_lib_ref: Option<Arc<YangLibraryReference>>,
394}
395
396impl CacheResponse {
397    pub const fn cached_content_id(&self) -> Option<&ContentId> {
398        self.cached_content_id.as_ref()
399    }
400
401    pub const fn subscription_info(&self) -> &SubscriptionInfo {
402        &self.subscription_info
403    }
404
405    pub fn yang_lib_ref(&self) -> Option<Arc<YangLibraryReference>> {
406        self.yang_lib_ref.as_ref().map(Arc::clone)
407    }
408}
409
410impl From<CacheResponse>
411    for (
412        Option<ContentId>,
413        SubscriptionInfo,
414        Option<Arc<YangLibraryReference>>,
415    )
416{
417    fn from(value: CacheResponse) -> Self {
418        (
419            value.cached_content_id,
420            value.subscription_info,
421            value.yang_lib_ref,
422        )
423    }
424}
425
426struct CacheActor<F: YangLibraryFetcher> {
427    cmd_rx: mpsc::Receiver<CacheActorCommand>,
428    schema_cache: YangLibraryCache,
429    requests: async_channel::Receiver<CacheLookupCommand>,
430    fetcher: F,
431    fetcher_timeout: Duration,
432    pending_requests: FxHashMap<SubscriptionInfo, Vec<async_channel::Sender<CacheResponse>>>,
433    workers_queue: FuturesUnordered<JoinHandle<FetcherResult>>,
434    stats: CachingStats,
435}
436
437impl<F: YangLibraryFetcher> CacheActor<F> {
438    async fn send_yang_lib_ref(
439        subscription_info: &SubscriptionInfo,
440        yang_lib_ref: Option<Arc<YangLibraryReference>>,
441        sender: async_channel::Sender<CacheResponse>,
442    ) {
443        let hit = yang_lib_ref.is_some();
444        let cached_content_id = yang_lib_ref
445            .as_ref()
446            .map(|x| x.content_id())
447            .cloned()
448            .unwrap_or_default();
449        let response = CacheResponse {
450            cached_content_id: yang_lib_ref.as_ref().map(|x| x.content_id().clone()),
451            subscription_info: subscription_info.clone(),
452            yang_lib_ref,
453        };
454        match sender.send(response).await {
455            Ok(_) => {
456                debug!(
457                    peer=%subscription_info.peer(),
458                    subscription_id=subscription_info.id(),
459                    router_content_id=subscription_info.content_id(),
460                    target=%subscription_info.target(),
461                    cached_content_id,
462                    hit,
463                    "yang library reference sent to requester"
464                );
465            }
466            Err(err) => {
467                warn!(
468                    peer=%subscription_info.peer(),
469                    subscription_id=subscription_info.id(),
470                    router_content_id=subscription_info.content_id(),
471                    target=%subscription_info.target(),
472                    cached_content_id,
473                    hit,
474                    error = %err,
475                    "failed to send yang library reference to requester"
476                );
477            }
478        }
479    }
480
481    async fn send_yang_lib_ref_content_id(
482        content_id: &ContentId,
483        yang_lib_ref: Option<Arc<YangLibraryReference>>,
484        sender: async_channel::Sender<(ContentId, Option<Arc<YangLibraryReference>>)>,
485    ) {
486        let found = yang_lib_ref.is_some();
487        let cached_content_id = yang_lib_ref.as_ref().map(|x| x.content_id().clone());
488        match sender.send((content_id.clone(), yang_lib_ref)).await {
489            Ok(_) => {
490                debug!(
491                    content_id,
492                    found,
493                    cached_content_id = cached_content_id.unwrap_or_default(),
494                    "yang library reference sent to requester"
495                );
496            }
497            Err(err) => {
498                warn!(
499                    content_id,
500                    found,
501                    cached_content_id=cached_content_id.unwrap_or_default(),
502                    error = %err,
503                    "failed to send yang library reference to requester"
504                );
505            }
506        }
507    }
508
509    fn send_yang_lib_ref_oneshot(
510        subscription_info: &SubscriptionInfo,
511        yang_lib_ref: Option<Arc<YangLibraryReference>>,
512        sender: oneshot::Sender<CacheResponse>,
513    ) {
514        let hit = yang_lib_ref.is_some();
515        let cached_content_id = yang_lib_ref
516            .as_ref()
517            .map(|x| x.content_id().clone())
518            .unwrap_or_default();
519        let response = CacheResponse {
520            cached_content_id: yang_lib_ref.as_ref().map(|x| x.content_id().clone()),
521            subscription_info: subscription_info.clone(),
522            yang_lib_ref,
523        };
524        match sender.send(response) {
525            Ok(_) => {
526                debug!(
527                    peer=%subscription_info.peer(),
528                    subscription_id=subscription_info.id(),
529                    router_content_id=subscription_info.content_id(),
530                    target=%subscription_info.target(),
531                    cached_content_id,
532                    hit,
533                    "yang library reference sent (oneshot) to requester"
534                );
535            }
536            Err(_err) => {
537                warn!(
538                    peer=%subscription_info.peer(),
539                    subscription_id=subscription_info.id(),
540                    router_content_id=subscription_info.content_id(),
541                    target=%subscription_info.target(),
542                    cached_content_id,
543                    "failed to send (oneshot) yang library reference to requester"
544                );
545            }
546        }
547    }
548
549    fn send_yang_lib_ref_oneshot_content_id(
550        content_id: &ContentId,
551        yang_lib_ref: Option<Arc<YangLibraryReference>>,
552        sender: oneshot::Sender<(ContentId, Option<Arc<YangLibraryReference>>)>,
553    ) {
554        let hit = yang_lib_ref.is_some();
555        match sender.send((content_id.clone(), yang_lib_ref)) {
556            Ok(_) => {
557                debug!(
558                    content_id,
559                    hit, "yang library reference sent (oneshot) to requester"
560                );
561            }
562            Err(_err) => {
563                warn!(
564                    content_id,
565                    hit, "failed to send (oneshot) yang library reference to requester"
566                );
567            }
568        }
569    }
570
571    async fn process_worker_result(&mut self, worker_result: Result<FetcherResult, JoinError>) {
572        match worker_result {
573            Err(err) => {
574                self.stats.device_fetch_failed.add(
575                    1,
576                    &[opentelemetry::KeyValue::new(
577                        "error.message",
578                        format!("{err}"),
579                    )],
580                );
581                warn!(error=%err, "cache actor worker failed to execute a task");
582            }
583            Ok(Err((subscription_info, err))) => {
584                self.stats.device_fetch_failed.add(
585                    1,
586                    &[
587                        opentelemetry::KeyValue::new(
588                            "network.peer.address",
589                            format!("{}", subscription_info.peer().ip()),
590                        ),
591                        opentelemetry::KeyValue::new(
592                            "network.peer.port",
593                            opentelemetry::Value::I64(subscription_info.peer().port().into()),
594                        ),
595                        opentelemetry::KeyValue::new(
596                            OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
597                            opentelemetry::Value::I64(subscription_info.id().into()),
598                        ),
599                        opentelemetry::KeyValue::new(
600                            OTL_YANG_PUSH_SUBSCRIPTION_TARGET_KEY,
601                            format!("{}", subscription_info.target()),
602                        ),
603                        opentelemetry::KeyValue::new(
604                            OTL_YANG_PUSH_SUBSCRIPTION_ROUTER_CONTENT_ID_KEY,
605                            subscription_info.content_id().to_string(),
606                        ),
607                        opentelemetry::KeyValue::new("error.message", format!("{err}")),
608                    ],
609                );
610                warn!(
611                    peer=%subscription_info.peer(),
612                    subscription_id=subscription_info.id(),
613                    router_content_id=subscription_info.content_id(),
614                    target=%subscription_info.target(),
615                    error=%err,
616                    "network fetcher failed to fetch yang library from device for subscription info"
617                );
618                let pending_senders = self.pending_requests.remove(&subscription_info);
619                self.stats
620                    .pending_cache_requests
621                    .record(self.pending_requests.len() as u64, &[]);
622                if let Some(pending_senders) = pending_senders {
623                    for sender in pending_senders {
624                        let _ = sender
625                            .send(CacheResponse {
626                                cached_content_id: None,
627                                subscription_info: subscription_info.clone(),
628                                yang_lib_ref: None,
629                            })
630                            .await
631                            .map_err(|error| {
632                                warn!(
633                                    peer=%subscription_info.peer(),
634                                    subscription_id=subscription_info.id(),
635                                    router_content_id=subscription_info.content_id(),
636                                    target=%subscription_info.target(),
637                                    error=%error,
638                                    "failed to send error response to requester"
639                                );
640                            });
641                        debug!(
642                            peer=%subscription_info.peer(),
643                            subscription_id=subscription_info.id(),
644                            router_content_id=subscription_info.content_id(),
645                            target=%subscription_info.target(),
646                            error=%err,
647                            "sent error response to requester due to fetch failure"
648                        );
649                    }
650                }
651            }
652            Ok(Ok((subscription_info, yang_lib, schemas))) => {
653                let otl_tags = [
654                    opentelemetry::KeyValue::new(
655                        "network.peer.address",
656                        format!("{}", subscription_info.peer().ip()),
657                    ),
658                    opentelemetry::KeyValue::new(
659                        "network.peer.port",
660                        opentelemetry::Value::I64(subscription_info.peer().port().into()),
661                    ),
662                    opentelemetry::KeyValue::new(
663                        OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
664                        opentelemetry::Value::I64(subscription_info.id().into()),
665                    ),
666                    opentelemetry::KeyValue::new(
667                        OTL_YANG_PUSH_SUBSCRIPTION_TARGET_KEY,
668                        format!("{}", subscription_info.target()),
669                    ),
670                    opentelemetry::KeyValue::new(
671                        OTL_YANG_PUSH_SUBSCRIPTION_ROUTER_CONTENT_ID_KEY,
672                        subscription_info.content_id().to_string(),
673                    ),
674                    opentelemetry::KeyValue::new(
675                        OTL_YANG_PUSH_CACHED_CONTENT_ID_KEY,
676                        yang_lib.content_id().to_string(),
677                    ),
678                ];
679                self.stats.device_fetch_succeeded.add(1, &otl_tags);
680                info!(
681                    subscription_id=subscription_info.id(),
682                    router_content_id=subscription_info.content_id(),
683                    target=%subscription_info.target(),
684                    modules_count = schemas.len(),
685                    cached_content_id = yang_lib.content_id(),
686                    "fetched yang library from device successfully"
687                );
688                let result = self.schema_cache.put_yang_library(
689                    subscription_info.clone(),
690                    yang_lib,
691                    schemas,
692                );
693                let yang_lib_ref = match result {
694                    Ok(yang_lib_ref) => {
695                        info!(
696                            peer=%subscription_info.peer(),
697                            subscription_id=subscription_info.id(),
698                            router_content_id=subscription_info.content_id(),
699                            target=%subscription_info.target(),
700                            "yang library for subscription info stored in cache successfully"
701                        );
702                        Some(yang_lib_ref)
703                    }
704                    Err(err) => {
705                        warn!(
706                            peer=%subscription_info.peer(),
707                            subscription_id=subscription_info.id(),
708                            router_content_id=subscription_info.content_id(),
709                            target=%subscription_info.target(),
710                            error=%err,
711                            "failed to store yang library in cache"
712                        );
713                        None
714                    }
715                };
716                // First, remove all pending requests for this subscription info that are
717                // requested with full subscription info
718                let empty = SubscriptionInfo::new_empty(
719                    subscription_info.collector(),
720                    subscription_info.interface(),
721                    subscription_info.peer(),
722                    subscription_info.id(),
723                );
724                let mut pending_senders = self
725                    .pending_requests
726                    .remove(&subscription_info)
727                    .unwrap_or(vec![]);
728                let pending_sender_by_id = self.pending_requests.remove(&empty).unwrap_or(vec![]);
729                pending_senders.extend(pending_sender_by_id);
730                self.stats
731                    .pending_cache_requests
732                    .record(self.pending_requests.len() as u64, &[]);
733                for sender in pending_senders {
734                    Self::send_yang_lib_ref(&subscription_info, yang_lib_ref.clone(), sender).await;
735                }
736            }
737        }
738    }
739
740    fn cleanup_closed_senders(&mut self) {
741        self.pending_requests.retain(|_, senders| {
742            senders.retain(|s| !s.is_closed());
743            !senders.is_empty()
744        });
745        self.stats
746            .pending_cache_requests
747            .record(self.pending_requests.len() as u64, &[]);
748    }
749
750    fn otl_tags_from_subscription_inf(
751        subscription_info: &SubscriptionInfo,
752    ) -> Vec<opentelemetry::KeyValue> {
753        Vec::from([
754            opentelemetry::KeyValue::new(
755                "network.peer.address",
756                format!("{}", subscription_info.peer().ip()),
757            ),
758            opentelemetry::KeyValue::new(
759                "network.peer.port",
760                opentelemetry::Value::I64(subscription_info.peer().port().into()),
761            ),
762            opentelemetry::KeyValue::new(
763                OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
764                opentelemetry::Value::I64(subscription_info.id().into()),
765            ),
766            opentelemetry::KeyValue::new(
767                OTL_YANG_PUSH_SUBSCRIPTION_TARGET_KEY,
768                format!("{}", subscription_info.target()),
769            ),
770            opentelemetry::KeyValue::new(
771                OTL_YANG_PUSH_SUBSCRIPTION_ROUTER_CONTENT_ID_KEY,
772                subscription_info.content_id().to_string(),
773            ),
774            opentelemetry::KeyValue::new(OTL_CACHE_REQUEST_TYPE, "LOOKUP_BY_SUBSCRIPTION_INFO"),
775        ])
776    }
777
778    async fn process_request(&mut self, request: CacheLookupCommand) {
779        match request {
780            CacheLookupCommand::LookupBySubscriptionInfo(subscription_info, sender) => {
781                let otl_tags = Self::otl_tags_from_subscription_inf(&subscription_info);
782                self.stats.requests_received.add(1, otl_tags.as_ref());
783                debug!(
784                    peer=%subscription_info.peer(),
785                    subscription_id=subscription_info.id(),
786                    router_content_id=subscription_info.content_id(),
787                    target=%subscription_info.target(),
788                    "processing cache lookup by subscription info request"
789                );
790                let yang_lib_ref = self
791                    .schema_cache
792                    .get_by_subscription_info(&subscription_info);
793                match yang_lib_ref {
794                    Some(yang_lib_ref) => {
795                        info!(
796                            peer=%subscription_info.peer(),
797                            subscription_id=subscription_info.id(),
798                            router_content_id=subscription_info.content_id(),
799                            target=%subscription_info.target(),
800                            cached_content_id=yang_lib_ref.content_id(),
801                            "cache hit: yang library reference found in cache"
802                        );
803                        self.stats.cache_hits.add(1, &otl_tags);
804                        Self::send_yang_lib_ref(&subscription_info, Some(yang_lib_ref), sender)
805                            .await;
806                    }
807                    None => {
808                        // YANG Library Reference is not found in the cache.
809                        // Start a new worker to fetch the YANG Library from the server.
810                        self.stats.cache_misses.add(1, &otl_tags);
811                        let entry = self
812                            .pending_requests
813                            .entry(subscription_info.clone())
814                            .or_default();
815                        let should_fetch = entry.is_empty();
816                        entry.push(sender);
817
818                        if should_fetch {
819                            info!(
820                                peer=%subscription_info.peer(),
821                                subscription_id=subscription_info.id(),
822                                router_content_id=subscription_info.content_id(),
823                                target=%subscription_info.target(),
824                                "cache miss: starting fetch from device"
825                            );
826                            self.stats.device_fetch_request.add(1, &otl_tags);
827                            let job_result = tokio::time::timeout(
828                                self.fetcher_timeout,
829                                self.fetcher.fetch(subscription_info.clone()),
830                            )
831                            .await;
832                            let job = match job_result {
833                                Ok(worker_result) => worker_result,
834                                Err(err) => {
835                                    warn!(
836                                        peer=%subscription_info.peer(),
837                                        subscription_id=subscription_info.id(),
838                                        router_content_id=subscription_info.content_id(),
839                                        target=%subscription_info.target(),
840                                        error=%err,
841                                        "failed to fetch yang library from device"
842                                    );
843                                    // remove the sender we just added since the fetch failed to
844                                    // start
845                                    entry.remove(entry.len() - 1);
846                                    self.stats.device_fetch_failed.add(1, &otl_tags);
847                                    return;
848                                }
849                            };
850                            self.workers_queue.push(job);
851                            self.stats
852                                .device_fetch_queue
853                                .record(self.workers_queue.len() as u64, &[]);
854                        } else {
855                            debug!(
856                                peer=%subscription_info.peer(),
857                                subscription_id=subscription_info.id(),
858                                router_content_id=subscription_info.content_id(),
859                                target=%subscription_info.target(),
860                                pending_requests_count=entry.len(),
861                                "cache miss: fetch request in progress from the router, queuing request to avoid duplicate requests to the router"
862                            );
863                        }
864                        self.stats
865                            .pending_cache_requests
866                            .record(self.pending_requests.len() as u64, &otl_tags);
867                    }
868                }
869            }
870            CacheLookupCommand::LookupBySubscriptionInfoOneShot(subscription_info, sender) => {
871                let mut otl_tags = Self::otl_tags_from_subscription_inf(&subscription_info);
872                self.stats.requests_received.add(1, otl_tags.as_ref());
873                debug!(
874                    peer=%subscription_info.peer(),
875                    subscription_id=subscription_info.id(),
876                    router_content_id=subscription_info.content_id(),
877                    target=%subscription_info.target(),
878                    "processing cache lookup by subscription info request (one shot)"
879                );
880                // fetch the schemas if needed from the device
881                if let Some(yang_lib_ref) = self
882                    .schema_cache
883                    .get_by_subscription_info(&subscription_info)
884                {
885                    info!(
886                        peer=%subscription_info.peer(),
887                        subscription_id=subscription_info.id(),
888                        router_content_id=subscription_info.content_id(),
889                        target=%subscription_info.target(),
890                        cached_content_id=yang_lib_ref.content_id(),
891                        "cache hit: yang library reference found in cache"
892                    );
893                    self.stats.cache_hits.add(1, &otl_tags);
894                } else {
895                    self.stats.cache_misses.add(1, &otl_tags);
896                    self.stats.device_fetch_request.add(1, &otl_tags);
897                    let worker_result = tokio::time::timeout(
898                        self.fetcher_timeout,
899                        self.fetcher.fetch_blocking(subscription_info.clone()),
900                    )
901                    .await;
902
903                    let worker_result = match worker_result {
904                        Ok(worker_result) => {
905                            self.stats.device_fetch_succeeded.add(1, &otl_tags);
906                            worker_result
907                        }
908                        Err(err) => {
909                            otl_tags.push(opentelemetry::KeyValue::new(
910                                "error.message",
911                                format!("{err}"),
912                            ));
913                            self.stats.device_fetch_failed.add(1, &otl_tags);
914                            Err((subscription_info.clone(), err.into()))
915                        }
916                    };
917                    self.process_worker_result(Ok(worker_result)).await;
918                }
919                let yang_lib_ref = self
920                    .schema_cache
921                    .get_by_subscription_info(&subscription_info.clone());
922                Self::send_yang_lib_ref_oneshot(&subscription_info, yang_lib_ref, sender);
923            }
924            CacheLookupCommand::LookupBySubscriptionId {
925                collector,
926                interface,
927                peer,
928                subscription_id,
929                tx,
930            } => {
931                let otel_tags = [
932                    opentelemetry::KeyValue::new("network.peer.address", format!("{}", peer.ip())),
933                    opentelemetry::KeyValue::new(
934                        "network.peer.port",
935                        opentelemetry::Value::I64(peer.port().into()),
936                    ),
937                    opentelemetry::KeyValue::new(
938                        OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
939                        opentelemetry::Value::I64(subscription_id.into()),
940                    ),
941                ];
942                self.stats.requests_received.add(1, &otel_tags);
943                debug!(
944                    peer=%peer,
945                    subscription_id,
946                    "processing cache lookup by subscription id request"
947                );
948                let response = self
949                    .schema_cache
950                    .get_by_subscription_id(peer.ip(), subscription_id);
951                if let Some((subscription_info, yang_lib_ref)) = response {
952                    self.stats.cache_hits.add(1, &otel_tags);
953                    Self::send_yang_lib_ref(&subscription_info, yang_lib_ref, tx).await;
954                } else {
955                    warn!(
956                        peer=%peer,
957                        subscription_id,
958                        "cache miss: subscription id not found in cache"
959                    );
960                    self.stats.cache_misses.add(1, &otel_tags);
961                    let subscription_info = SubscriptionInfo::new_empty(
962                        collector,
963                        interface.clone(),
964                        peer,
965                        subscription_id,
966                    );
967                    let entry = self
968                        .pending_requests
969                        .entry(subscription_info.clone())
970                        .or_default();
971                    let should_fetch = entry.is_empty();
972                    entry.push(tx);
973
974                    if should_fetch {
975                        info!(
976                            peer=%peer,
977                            subscription_id,
978                            "cache miss: starting fetch from device by subscription id"
979                        );
980                        self.stats.device_fetch_request.add(1, &otel_tags);
981                        let job_result = tokio::time::timeout(
982                            self.fetcher_timeout,
983                            self.fetcher.fetch_by_subscription_id(
984                                collector,
985                                interface.clone().map(String::into_boxed_str),
986                                peer,
987                                subscription_id,
988                            ),
989                        )
990                        .await;
991                        let job = match job_result {
992                            Ok(worker_result) => worker_result,
993                            Err(err) => {
994                                warn!(
995                                    peer=%peer,
996                                    subscription_id,
997                                    router_content_id=subscription_info.clone().content_id().clone(),
998                                    target=%subscription_info.target(),
999                                    error=%err,
1000                                    "failed to fetch yang library from device"
1001                                );
1002                                // remove the sender we just added since the fetch failed to
1003                                // start
1004                                entry.remove(entry.len() - 1);
1005                                self.stats.device_fetch_failed.add(1, &otel_tags);
1006                                return;
1007                            }
1008                        };
1009                        self.workers_queue.push(job);
1010                        self.stats
1011                            .device_fetch_queue
1012                            .record(self.workers_queue.len() as u64, &[]);
1013                    } else {
1014                        debug!(
1015                            peer=%subscription_info.peer(),
1016                            subscription_id=subscription_info.id(),
1017                            router_content_id=subscription_info.content_id(),
1018                            target=%subscription_info.target(),
1019                            pending_requests_count=entry.len(),
1020                            "cache miss: fetch request in progress from the router, queuing request to avoid duplicate requests to the router"
1021                        );
1022                    }
1023                    self.stats
1024                        .pending_cache_requests
1025                        .record(self.pending_requests.len() as u64, &otel_tags);
1026                }
1027            }
1028            CacheLookupCommand::LookupBySubscriptionIdOneShot {
1029                collector,
1030                interface,
1031                peer,
1032                subscription_id,
1033                tx,
1034            } => {
1035                let mut otel_tags = vec![
1036                    opentelemetry::KeyValue::new("network.peer.address", format!("{}", peer.ip())),
1037                    opentelemetry::KeyValue::new(
1038                        "network.peer.port",
1039                        opentelemetry::Value::I64(peer.port().into()),
1040                    ),
1041                    opentelemetry::KeyValue::new(
1042                        OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
1043                        opentelemetry::Value::I64(subscription_id.into()),
1044                    ),
1045                ];
1046                self.stats.requests_received.add(1, &otel_tags);
1047                debug!(
1048                    peer=%peer,
1049                    subscription_id,
1050                    "processing cache lookup by subscription id request (one shot)");
1051                let response = self
1052                    .schema_cache
1053                    .get_by_subscription_id(peer.ip(), subscription_id);
1054                if let Some((subscription_info, yang_lib_ref)) = response {
1055                    self.stats.cache_hits.add(1, &otel_tags);
1056                    Self::send_yang_lib_ref_oneshot(&subscription_info, yang_lib_ref, tx);
1057                } else {
1058                    warn!(
1059                        peer=%peer,
1060                        subscription_id,
1061                        "cache miss: subscription id not found in cache"
1062                    );
1063                    self.stats.cache_misses.add(1, &otel_tags);
1064                    self.stats.device_fetch_request.add(1, &otel_tags);
1065
1066                    let worker_result = tokio::time::timeout(
1067                        self.fetcher_timeout,
1068                        self.fetcher.fetch_by_subscription_id_blocking(
1069                            collector,
1070                            interface.clone().map(String::into_boxed_str),
1071                            peer,
1072                            subscription_id,
1073                        ),
1074                    )
1075                    .await;
1076                    let worker_result = match worker_result {
1077                        Ok(worker_result) => {
1078                            self.stats.device_fetch_succeeded.add(1, &otel_tags);
1079                            worker_result
1080                        }
1081                        Err(err) => {
1082                            otel_tags.push(opentelemetry::KeyValue::new(
1083                                "error.message",
1084                                format!("{err}"),
1085                            ));
1086                            self.stats.device_fetch_failed.add(1, &otel_tags);
1087                            let empty = SubscriptionInfo::new_empty(
1088                                collector,
1089                                interface,
1090                                peer,
1091                                subscription_id,
1092                            );
1093                            Err((empty.clone(), err.into()))
1094                        }
1095                    };
1096                    self.process_worker_result(Ok(worker_result)).await;
1097                }
1098            }
1099            CacheLookupCommand::LookupByContentId(content_id, sender) => {
1100                let otl_tags = [opentelemetry::KeyValue::new(
1101                    OTL_YANG_PUSH_CACHED_CONTENT_ID_KEY,
1102                    content_id.to_string(),
1103                )];
1104                self.stats.requests_received.add(1, &otl_tags);
1105                debug!(content_id, "processing cache lookup request by content id");
1106                let yang_lib_ref = self.schema_cache.get_by_content_id(&content_id);
1107                if yang_lib_ref.is_some() {
1108                    self.stats.cache_hits.add(1, &otl_tags);
1109                } else {
1110                    self.stats.cache_misses.add(1, &otl_tags);
1111                }
1112                Self::send_yang_lib_ref_content_id(&content_id, yang_lib_ref, sender).await;
1113            }
1114            CacheLookupCommand::LookupByContentIdOneShot(content_id, sender) => {
1115                let otl_tags = [opentelemetry::KeyValue::new(
1116                    OTL_YANG_PUSH_CACHED_CONTENT_ID_KEY,
1117                    content_id.to_string(),
1118                )];
1119                self.stats.requests_received.add(1, &otl_tags);
1120                debug!(
1121                    content_id,
1122                    "processing cache lookup request by content id (one shot)"
1123                );
1124                let yang_lib_ref = self.schema_cache.get_by_content_id(&content_id);
1125                if yang_lib_ref.is_some() {
1126                    self.stats.cache_hits.add(1, &otl_tags);
1127                } else {
1128                    self.stats.cache_misses.add(1, &otl_tags);
1129                }
1130                Self::send_yang_lib_ref_oneshot_content_id(&content_id, yang_lib_ref, sender);
1131            }
1132        }
1133    }
1134
1135    async fn run(mut self) -> Result<String, CacheActorCacheError> {
1136        loop {
1137            tokio::select! {
1138                biased;
1139                cmd = self.cmd_rx.recv() => {
1140                    match cmd {
1141                        Some(CacheActorCommand::Shutdown) => {
1142                            info!(
1143                                pending_requests = self.pending_requests.len(),
1144                                active_workers = self.workers_queue.len(),
1145                                "cache actor shutting down and aborting all active workers"
1146                            );
1147                            for task in self.workers_queue {
1148                                task.abort();
1149                            }
1150                            return Ok("Schema cache shutdown successful".to_string());
1151                        }
1152                        Some(CacheActorCommand::Cleanup) => {
1153                            self.cleanup_closed_senders();
1154                        }
1155                        None => {
1156                            warn!(
1157                                pending_requests = self.pending_requests.len(),
1158                                active_workers = self.workers_queue.len(),
1159                                "cache actor channel closed unexpectedly due to closed command channel"
1160                            );
1161                            for task in self.workers_queue {
1162                                task.abort();
1163                            }
1164                            self.stats.device_fetch_queue.record(0, &[]);
1165                            return Ok("Schema cache shutdown successful".to_string());
1166                        }
1167                    }
1168                }
1169                work_result = self.workers_queue.next(), if !self.workers_queue.is_empty()=> {
1170                    if let Some(work_result) = work_result {
1171                        self.stats.device_fetch_queue.record(self.workers_queue.len() as u64, &[]);
1172                        self.process_worker_result(work_result).await;
1173                    }
1174                }
1175                request = self.requests.recv() => {
1176                    if let Ok(request) = request {
1177                        self.process_request(request).await;
1178                    } else {
1179                        self.stats.requests_received.add(1, &[]);
1180                        warn!(
1181                            pending_requests = self.pending_requests.len(),
1182                            active_workers = self.workers_queue.len(),
1183                            "cache actor channel closed unexpectedly due to closed request channel"
1184                        );
1185                        for task in self.workers_queue {
1186                            task.abort();
1187                        }
1188                        self.stats.device_fetch_queue.record(0, &[]);
1189                        return Ok("Schema cache shutdown successful".to_string())
1190                    }
1191                }
1192            }
1193        }
1194    }
1195}
1196
1197#[derive(Debug, strum_macros::Display)]
1198pub enum CacheActorHandleError {
1199    #[strum(to_string = "failed to send command to the schema caching actor")]
1200    SendError,
1201
1202    #[strum(to_string = "failed to fetch yang library from device {0}")]
1203    CacheError(YangLibraryCacheError),
1204}
1205
1206impl std::error::Error for CacheActorHandleError {}
1207
1208impl From<YangLibraryCacheError> for CacheActorHandleError {
1209    fn from(err: YangLibraryCacheError) -> Self {
1210        CacheActorHandleError::CacheError(err)
1211    }
1212}
1213
1214#[derive(Debug, Clone)]
1215pub struct CacheActorHandle {
1216    cmd_tx: mpsc::Sender<CacheActorCommand>,
1217    requests_tx: async_channel::Sender<CacheLookupCommand>,
1218}
1219
1220impl CacheActorHandle {
1221    pub fn new<F: YangLibraryFetcher + Send + Sync + 'static>(
1222        buffer_size: usize,
1223        schema_cache: either::Either<YangLibraryCache, PathBuf>,
1224        fetcher: F,
1225        fetcher_timeout: Duration,
1226        stats: either::Either<opentelemetry::metrics::Meter, CachingStats>,
1227    ) -> Result<(JoinHandle<Result<String, CacheActorCacheError>>, Self), CacheActorHandleError>
1228    {
1229        let (cmd_tx, cmd_rx) = mpsc::channel(10);
1230        let (requests_tx, requests_rx) = async_channel::bounded(buffer_size);
1231        let schema_cache = match schema_cache {
1232            either::Either::Left(schema_cache) => schema_cache,
1233            either::Either::Right(root_path) => YangLibraryCache::from_disk(root_path)?,
1234        };
1235        let stats = match stats {
1236            either::Either::Left(meter) => CachingStats::new(meter),
1237            either::Either::Right(stats) => stats,
1238        };
1239
1240        let actor = CacheActor {
1241            cmd_rx,
1242            schema_cache,
1243            requests: requests_rx,
1244            fetcher,
1245            fetcher_timeout,
1246            pending_requests: FxHashMap::default(),
1247            workers_queue: FuturesUnordered::new(),
1248            stats,
1249        };
1250
1251        let cmd_tx_clone = cmd_tx.clone();
1252        let handle = Self {
1253            cmd_tx,
1254            requests_tx,
1255        };
1256        let join_handle = tokio::spawn(actor.run());
1257
1258        tokio::spawn(async move {
1259            let mut cleanup_interval = tokio::time::interval(Duration::from_secs(300));
1260            loop {
1261                cleanup_interval.tick().await;
1262                if let Err(err) = cmd_tx_clone.send(CacheActorCommand::Cleanup).await {
1263                    warn!(error=%err, "failed to send cleanup command to cache actor, stopping periodic cleanup task");
1264                    break;
1265                }
1266            }
1267        });
1268        Ok((join_handle, handle))
1269    }
1270
1271    pub async fn shutdown(self) -> Result<(), CacheActorHandleError> {
1272        self.cmd_tx
1273            .send(CacheActorCommand::Shutdown)
1274            .await
1275            .map_err(|_| CacheActorHandleError::SendError)
1276    }
1277
1278    pub fn request_tx(&self) -> async_channel::Sender<CacheLookupCommand> {
1279        self.requests_tx.clone()
1280    }
1281}
1282
1283#[cfg(test)]
1284pub(crate) mod tests {
1285    use super::*;
1286    use crate::cache::fetcher::tests::TestYangLibFetcher;
1287    use futures_util::stream::FuturesOrdered;
1288    use netgauze_netconf_proto::yang_push::identities::{Encoding, Transport};
1289    use netgauze_netconf_proto::yang_push::subscription::{UpdateTrigger, YangPushModuleVersion};
1290    use netgauze_udp_notif_pkt::notification::Target;
1291    use std::collections::HashMap;
1292    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1293    use std::path::Path;
1294    use std::time::Duration;
1295
1296    pub(crate) fn test_subscription_info() -> SubscriptionInfo {
1297        SubscriptionInfo::new(
1298            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 10000),
1299            None,
1300            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 200)), 830),
1301            1,
1302            Target::new_datastore(
1303                "ds:operational".into(),
1304                either::Right("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics".into()),
1305            ),
1306            None,
1307            Some(Transport::UDPNotif),
1308            Some(Encoding::Json),
1309            Some("test subscription".into()),
1310            Some(UpdateTrigger::OnChange {
1311                dampening_period: None,
1312                sync_on_start: Some(true),
1313                excluded_change: None,
1314            }),
1315            Box::new([
1316                YangPushModuleVersion::new("ietf-interfaces".into(), Some("2018-02-20".into()), None),
1317                YangPushModuleVersion::new("ietf-ip".into(), None, None),
1318            ]),
1319            ContentId::from("ietf-interfaces-lib".to_string()),
1320        )
1321    }
1322
1323    fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
1324        std::fs::create_dir_all(&dst)?;
1325        for entry in std::fs::read_dir(src)? {
1326            let entry = entry?;
1327            let ty = entry.file_type()?;
1328            if ty.is_dir() {
1329                copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
1330            } else {
1331                std::fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
1332            }
1333        }
1334        Ok(())
1335    }
1336
1337    #[allow(clippy::type_complexity)]
1338    pub(crate) fn setup_actor_with_loaded_cache() -> (
1339        JoinHandle<Result<String, CacheActorCacheError>>,
1340        CacheActorHandle,
1341        YangLibraryReference,
1342        SubscriptionInfo,
1343        Arc<std::sync::Mutex<HashMap<SubscriptionInfo, usize>>>,
1344    ) {
1345        let cache_dir = tempfile::tempdir().unwrap();
1346        let yang_lib_ref_path = cache_dir.path().to_path_buf().join("ietf-interfaces-lib");
1347        copy_dir_all(
1348            "../../assets/yang/ietf-interfaces",
1349            yang_lib_ref_path.as_path(),
1350        )
1351        .unwrap();
1352        let yang_lib_path = yang_lib_ref_path.join("yang-lib.xml");
1353        let yang_lib_ref = YangLibraryReference::load_from_disk(yang_lib_path, yang_lib_ref_path)
1354            .expect("Failed to load yang library reference from disk");
1355        let subscription_info = yang_lib_ref
1356            .subscriptions_info()
1357            .expect("Failed to get subscriptions info")[0]
1358            .clone();
1359        let yang_lib = yang_lib_ref.yang_library().unwrap();
1360        let schemas = yang_lib_ref.load_schemas().unwrap();
1361        let fetcher = TestYangLibFetcher::new(HashMap::from([(
1362            subscription_info.clone(),
1363            (yang_lib, schemas),
1364        )]));
1365        let fetcher_count = Arc::clone(&fetcher.fetch_counts);
1366        let (join, handle) = CacheActorHandle::new(
1367            100,
1368            either::Right(cache_dir.path().to_path_buf()),
1369            fetcher,
1370            Duration::from_secs(1),
1371            either::Either::Left(opentelemetry::global::meter("test-meter")),
1372        )
1373        .expect("Failed to create cache actor");
1374        (join, handle, yang_lib_ref, subscription_info, fetcher_count)
1375    }
1376
1377    #[allow(clippy::type_complexity)]
1378    pub(crate) fn setup_actor_with_empty_cache() -> (
1379        JoinHandle<Result<String, CacheActorCacheError>>,
1380        CacheActorHandle,
1381        SubscriptionInfo,
1382        Arc<std::sync::Mutex<HashMap<SubscriptionInfo, usize>>>,
1383    ) {
1384        // Keep the YANG lib reference in a different directory than the cache
1385        let fetcher_dir = tempfile::tempdir().unwrap();
1386        let yang_lib_ref_path = fetcher_dir.path().to_path_buf().join("ietf-interfaces-lib");
1387        copy_dir_all(
1388            "../../assets/yang/ietf-interfaces",
1389            yang_lib_ref_path.as_path(),
1390        )
1391        .unwrap();
1392        let yang_lib_path = yang_lib_ref_path.join("yang-lib.xml");
1393        let yang_lib_ref = YangLibraryReference::load_from_disk(yang_lib_path, yang_lib_ref_path)
1394            .expect("Failed to load yang library reference from disk");
1395        let subscription_info = yang_lib_ref
1396            .subscriptions_info()
1397            .expect("Failed to get subscriptions info")[0]
1398            .clone();
1399        let yang_lib = yang_lib_ref.yang_library().unwrap();
1400        let schemas = yang_lib_ref.load_schemas().unwrap();
1401        let fetcher = TestYangLibFetcher::new(HashMap::from([(
1402            subscription_info.clone(),
1403            (yang_lib, schemas),
1404        )]));
1405        let fetcher_count = Arc::clone(&fetcher.fetch_counts);
1406
1407        let cache_dir = tempfile::tempdir().unwrap();
1408        let (join, handle) = CacheActorHandle::new(
1409            100,
1410            either::Right(cache_dir.path().to_path_buf()),
1411            fetcher,
1412            Duration::from_secs(1),
1413            either::Either::Left(opentelemetry::global::meter("test-meter")),
1414        )
1415        .expect("Failed to create cache actor");
1416        (join, handle, subscription_info, fetcher_count)
1417    }
1418
1419    #[tokio::test]
1420    #[tracing_test::traced_test]
1421    async fn test_cache_miss_device_fail() {
1422        let (join_handle, handle, _cached_lib_ref, _cached_subscription_info, fetcher_count) =
1423            setup_actor_with_loaded_cache();
1424        {
1425            let hits_counts = fetcher_count
1426                .lock()
1427                .expect("Failed to lock fetcher counts")
1428                .clone();
1429            assert_eq!(hits_counts.len(), 0);
1430        }
1431        let subscription_info = test_subscription_info();
1432
1433        let (tx, rx) = async_channel::unbounded();
1434        handle
1435            .request_tx()
1436            .send(CacheLookupCommand::LookupBySubscriptionInfo(
1437                subscription_info.clone(),
1438                tx,
1439            ))
1440            .await
1441            .unwrap();
1442
1443        tokio::task::yield_now().await;
1444        let response = tokio::time::timeout(Duration::from_millis(1000), rx.recv())
1445            .await
1446            .expect("timeout waiting for response")
1447            .expect("failed to receive response");
1448        assert_eq!(response.subscription_info(), &subscription_info);
1449        assert_eq!(response.yang_lib_ref(), None);
1450
1451        // check that the fetcher was called
1452        {
1453            let hits_counts = fetcher_count
1454                .lock()
1455                .expect("Failed to lock fetcher counts")
1456                .clone();
1457            assert_eq!(hits_counts.len(), 1);
1458            assert_eq!(hits_counts.get(&subscription_info), Some(&1));
1459        }
1460
1461        tokio::time::timeout(Duration::from_millis(100), handle.shutdown())
1462            .await
1463            .expect("timeout during shutdown")
1464            .expect("failed to shutdown actor");
1465        tokio::time::timeout(Duration::from_millis(100), join_handle)
1466            .await
1467            .expect("timeout during join")
1468            .expect("failed to join actor")
1469            .expect("cache actor failed");
1470    }
1471
1472    #[tokio::test]
1473    #[tracing_test::traced_test]
1474    async fn test_cache_miss_triggers_fetch() {
1475        let (join_handle, handle, subscription_info, fetcher_count) =
1476            setup_actor_with_empty_cache();
1477        {
1478            let hits_counts = fetcher_count
1479                .lock()
1480                .expect("Failed to lock fetcher counts")
1481                .clone();
1482            assert_eq!(hits_counts.len(), 0);
1483        }
1484
1485        let (tx, rx) = async_channel::unbounded();
1486        handle
1487            .request_tx()
1488            .send(CacheLookupCommand::LookupBySubscriptionInfo(
1489                subscription_info.clone(),
1490                tx,
1491            ))
1492            .await
1493            .unwrap();
1494
1495        tokio::task::yield_now().await;
1496        let response = tokio::time::timeout(Duration::from_millis(1000), rx.recv())
1497            .await
1498            .expect("timeout waiting for response")
1499            .expect("failed to receive response");
1500        assert_eq!(response.subscription_info(), &subscription_info);
1501        assert!(response.yang_lib_ref().is_some());
1502
1503        // check that the fetcher was called
1504        {
1505            let hits_counts = fetcher_count
1506                .lock()
1507                .expect("Failed to lock fetcher counts")
1508                .clone();
1509            assert_eq!(hits_counts.len(), 1);
1510            assert_eq!(hits_counts.get(&subscription_info), Some(&1));
1511        }
1512
1513        let (tx, rx) = async_channel::unbounded();
1514        handle
1515            .request_tx()
1516            .send(CacheLookupCommand::LookupBySubscriptionInfo(
1517                subscription_info.clone(),
1518                tx,
1519            ))
1520            .await
1521            .unwrap();
1522
1523        tokio::task::yield_now().await;
1524        let response = tokio::time::timeout(Duration::from_millis(1000), rx.recv())
1525            .await
1526            .expect("timeout waiting for response")
1527            .expect("failed to receive response");
1528        assert_eq!(response.subscription_info(), &subscription_info);
1529        assert!(response.yang_lib_ref().is_some());
1530
1531        // check that the fetcher was NOT called
1532        {
1533            let hits_counts = fetcher_count
1534                .lock()
1535                .expect("Failed to lock fetcher counts")
1536                .clone();
1537            assert_eq!(hits_counts.len(), 1);
1538            assert_eq!(hits_counts.get(&subscription_info), Some(&1));
1539        }
1540
1541        tokio::time::timeout(Duration::from_millis(100), handle.shutdown())
1542            .await
1543            .expect("timeout during shutdown")
1544            .expect("failed to shutdown actor");
1545        tokio::time::timeout(Duration::from_millis(100), join_handle)
1546            .await
1547            .expect("timeout during join")
1548            .expect("failed to join actor")
1549            .expect("cache actor failed");
1550    }
1551
1552    #[tokio::test]
1553    async fn test_concurrent_fetch_deduplication() {
1554        let (join_handle, handle, subscription_info, fetcher_count) =
1555            setup_actor_with_empty_cache();
1556
1557        let mut tasks = FuturesOrdered::new();
1558        for _ in 0..10 {
1559            let h = handle.clone();
1560            let sub = subscription_info.clone();
1561            tasks.push_back(tokio::spawn(async move {
1562                let (tx, rx) = async_channel::unbounded();
1563                h.request_tx()
1564                    .send(CacheLookupCommand::LookupBySubscriptionInfo(sub, tx))
1565                    .await
1566                    .unwrap();
1567                tokio::time::timeout(Duration::from_secs(1), rx.recv())
1568                    .await
1569                    .expect("timeout waiting for response")
1570                    .unwrap()
1571            }));
1572        }
1573
1574        let results = tasks.collect::<Vec<_>>().await;
1575        assert_eq!(results.len(), 10);
1576
1577        // Verify only ONE fetch occurred
1578        {
1579            let count = fetcher_count.lock().unwrap();
1580            assert_eq!(count.get(&subscription_info), Some(&1));
1581        }
1582
1583        // Cleanup
1584        tokio::time::timeout(Duration::from_millis(100), handle.shutdown())
1585            .await
1586            .expect("timeout during shutdown")
1587            .expect("failed to shutdown actor");
1588        tokio::time::timeout(Duration::from_millis(100), join_handle)
1589            .await
1590            .expect("timeout during join")
1591            .expect("failed to join actor")
1592            .expect("cache actor failed");
1593    }
1594
1595    #[tokio::test]
1596    #[tracing_test::traced_test]
1597    async fn test_cache_miss_device_fail_by_id() {
1598        let (join_handle, handle, _cached_lib_ref, _cached_subscription_info, fetcher_count) =
1599            setup_actor_with_loaded_cache();
1600        {
1601            let hits_counts = fetcher_count
1602                .lock()
1603                .expect("Failed to lock fetcher counts")
1604                .clone();
1605            assert_eq!(hits_counts.len(), 0);
1606        }
1607        let subscription_info = test_subscription_info();
1608
1609        let (tx, rx) = async_channel::unbounded();
1610        handle
1611            .request_tx()
1612            .send(CacheLookupCommand::LookupBySubscriptionId {
1613                collector: subscription_info.collector(),
1614                interface: subscription_info.interface(),
1615                peer: subscription_info.peer(),
1616                subscription_id: subscription_info.id(),
1617                tx,
1618            })
1619            .await
1620            .unwrap();
1621
1622        tokio::task::yield_now().await;
1623        tokio::time::sleep(Duration::from_millis(100)).await;
1624        let response = tokio::time::timeout(Duration::from_millis(1000), rx.recv())
1625            .await
1626            .expect("timeout waiting for response")
1627            .expect("failed to receive response");
1628        assert_eq!(
1629            response.subscription_info(),
1630            &SubscriptionInfo::new_empty(
1631                subscription_info.collector(),
1632                subscription_info.interface(),
1633                subscription_info.peer(),
1634                subscription_info.id(),
1635            )
1636        );
1637        assert_eq!(response.yang_lib_ref(), None);
1638
1639        // check that the fetcher was called
1640        {
1641            let hits_counts = fetcher_count
1642                .lock()
1643                .expect("Failed to lock fetcher counts")
1644                .clone();
1645            assert_eq!(hits_counts.len(), 1);
1646            assert_eq!(
1647                hits_counts.get(&SubscriptionInfo::new_empty(
1648                    subscription_info.collector(),
1649                    subscription_info.interface(),
1650                    subscription_info.peer(),
1651                    subscription_info.id()
1652                )),
1653                Some(&1)
1654            );
1655        }
1656
1657        tokio::time::timeout(Duration::from_millis(100), handle.shutdown())
1658            .await
1659            .expect("timeout during shutdown")
1660            .expect("failed to shutdown actor");
1661        tokio::time::timeout(Duration::from_millis(100), join_handle)
1662            .await
1663            .expect("timeout during join")
1664            .expect("failed to join actor")
1665            .expect("cache actor failed");
1666    }
1667
1668    #[tokio::test]
1669    #[tracing_test::traced_test]
1670    async fn test_cache_miss_triggers_fetch_by_id() {
1671        let (join_handle, handle, subscription_info, fetcher_count) =
1672            setup_actor_with_empty_cache();
1673        {
1674            let hits_counts = fetcher_count
1675                .lock()
1676                .expect("Failed to lock fetcher counts")
1677                .clone();
1678            assert_eq!(hits_counts.len(), 0);
1679        }
1680
1681        let (tx, rx) = async_channel::unbounded();
1682        handle
1683            .request_tx()
1684            .send(CacheLookupCommand::LookupBySubscriptionId {
1685                collector: subscription_info.collector(),
1686                interface: subscription_info.interface(),
1687                peer: subscription_info.peer(),
1688                subscription_id: subscription_info.id(),
1689                tx,
1690            })
1691            .await
1692            .unwrap();
1693
1694        tokio::task::yield_now().await;
1695        let response = tokio::time::timeout(Duration::from_millis(1000), rx.recv())
1696            .await
1697            .expect("timeout waiting for response")
1698            .expect("failed to receive response");
1699        assert_eq!(response.subscription_info(), &subscription_info);
1700        assert!(response.yang_lib_ref().is_some());
1701
1702        // check that the fetcher was called
1703        {
1704            let hits_counts = fetcher_count
1705                .lock()
1706                .expect("Failed to lock fetcher counts")
1707                .clone();
1708            assert_eq!(hits_counts.len(), 1);
1709            assert_eq!(hits_counts.get(&subscription_info), Some(&1));
1710        }
1711
1712        let (tx, rx) = async_channel::unbounded();
1713        handle
1714            .request_tx()
1715            .send(CacheLookupCommand::LookupBySubscriptionInfo(
1716                subscription_info.clone(),
1717                tx,
1718            ))
1719            .await
1720            .unwrap();
1721
1722        tokio::task::yield_now().await;
1723        let response = tokio::time::timeout(Duration::from_millis(1000), rx.recv())
1724            .await
1725            .expect("timeout waiting for response")
1726            .expect("failed to receive response");
1727        assert_eq!(response.subscription_info(), &subscription_info);
1728        assert!(response.yang_lib_ref().is_some());
1729
1730        // check that the fetcher was NOT called
1731        {
1732            let hits_counts = fetcher_count
1733                .lock()
1734                .expect("Failed to lock fetcher counts")
1735                .clone();
1736            assert_eq!(hits_counts.len(), 1);
1737            assert_eq!(hits_counts.get(&subscription_info), Some(&1));
1738        }
1739
1740        tokio::time::timeout(Duration::from_millis(100), handle.shutdown())
1741            .await
1742            .expect("timeout during shutdown")
1743            .expect("failed to shutdown actor");
1744        tokio::time::timeout(Duration::from_millis(100), join_handle)
1745            .await
1746            .expect("timeout during join")
1747            .expect("failed to join actor")
1748            .expect("cache actor failed");
1749    }
1750
1751    #[tokio::test]
1752    async fn test_concurrent_fetch_deduplication_by_id() {
1753        let (join_handle, handle, subscription_info, fetcher_count) =
1754            setup_actor_with_empty_cache();
1755
1756        let mut tasks = FuturesOrdered::new();
1757        for _ in 0..10 {
1758            let h = handle.clone();
1759            let sub = subscription_info.clone();
1760            tasks.push_back(tokio::spawn(async move {
1761                let (tx, rx) = async_channel::unbounded();
1762                h.request_tx()
1763                    .send(CacheLookupCommand::LookupBySubscriptionId {
1764                        collector: sub.collector(),
1765                        interface: sub.interface(),
1766                        peer: sub.peer(),
1767                        subscription_id: sub.id(),
1768                        tx,
1769                    })
1770                    .await
1771                    .unwrap();
1772                rx.recv().await.unwrap()
1773            }));
1774        }
1775
1776        let results = tasks
1777            .collect::<Vec<_>>()
1778            .await
1779            .into_iter()
1780            .map(|res| res.unwrap())
1781            .collect::<Vec<_>>();
1782        assert_eq!(results.len(), 10);
1783
1784        // Verify only ONE fetch occurred
1785        {
1786            let count = fetcher_count.lock().unwrap();
1787            assert_eq!(count.get(&subscription_info), Some(&1));
1788        }
1789
1790        // Cleanup
1791        tokio::time::timeout(Duration::from_millis(100), handle.shutdown())
1792            .await
1793            .expect("timeout during shutdown")
1794            .expect("failed to shutdown actor");
1795        tokio::time::timeout(Duration::from_millis(100), join_handle)
1796            .await
1797            .expect("timeout during join")
1798            .expect("failed to join actor")
1799            .expect("cache actor failed");
1800    }
1801}