Skip to main content

netgauze_yang_push/validation/
mod.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//! YANG Push Notification Validation Actor
17//!
18//! This module provides an actor-based validation system for UDP-Notif
19//! packets carrying YANG-modeled data. The actor validates notification
20//! payloads against YANG schemas when available, gracefully handling
21//! cases where schemas haven't been loaded yet.
22//!
23//! ## Quick Start
24//!
25//! ```rust,ignore
26//! use netgauze_yang_push::validation::ValidationActorHandle;
27//!
28//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
29//! let (rx, tx, cache_cmd_tx) = /* channel setup */;
30//! let (join_handle, handle) = ValidationActorHandle::new(
31//!     1000,  // max packets cached per peer
32//!     100,   // max packets cached per subscription
33//!     rx,    // incoming UDP-Notif packets
34//!     tx,    // validated packets output
35//!     cache_cmd_tx,  // cache lookup commands
36//! )?;
37//!
38//! // Actor runs in background...
39//! handle.shutdown().await?;
40//! join_handle.await??;
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! ## Architecture
46//!
47//! ### Packet Processing Pipeline
48//!
49//! 1. **Receive**: UDP-Notif packets arrive from the network layer
50//! 2. **Decode**: Extract subscription ID and notification type
51//! 3. **Bootstrap**: `SubscriptionStarted` notifications trigger YANG library
52//!    lookups via the cache actor
53//! 4. **Cache or Validate**:
54//!    - If YANG schema available: Validate and forward
55//!    - If schema pending: Cache packet until schema arrives
56//!    - If schema unavailable: Forward unvalidated with empty subscription info
57//! 5. **Forward**: Send validated/unvalidated packets downstream
58//!
59//! ### Two-Level Caching
60//!
61//! The actor maintains caches at two levels to handle the asynchronous nature
62//! of YANG schema retrieval:
63//!
64//! - **Peer Level**: Groups all subscriptions from the same source IP
65//!   - Enforces `max_cached_packets_per_peer` limit across all subscriptions
66//!   - Prevents a single peer from consuming excessive memory
67//!
68//! - **Subscription Level**: Per-subscription state including:
69//!   - `SubscriptionInfo`: Metadata from `SubscriptionStarted`
70//!   - `yang5::Context`: Loaded YANG schemas for validation
71//!   - Buffered packets waiting for schema retrieval
72//!   - Enforces `max_cached_packets_per_subscription` limit
73//!
74//! Packets arriving before schemas are loaded are cached and reprocessed
75//! when the cache actor responds with YANG library references.
76//!
77//! ### Cache Limits
78//!
79//! Two configurable limits prevent memory exhaustion:
80//! - **Per-subscription limit**: protects against slow schema retrieval
81//! - **Per-peer limit**: protects against malicious peers creating many
82//!   subscriptions
83//!
84//! When limits are exceeded, new packets are dropped with a warning logged.
85//!
86//! ## Validation Behavior
87//!
88//! The actor validates packets when YANG schemas are available:
89//!
90//! - **Schema available**: Validates using `yang5` library
91//!   - Valid packets → forwarded with full `SubscriptionInfo`
92//!   - Invalid packets → dropped with error logged
93//!
94//! - **Schema unavailable**: Forwards unvalidated
95//!   - Marked with empty `SubscriptionInfo` (content_id = "EMPTY")
96//!   - Downstream can detect and handle unvalidated packets
97//!
98//! - **Schema loading failed**: Disables validation for subscription
99//!   - All future packets forwarded unvalidated
100//!   - Warning logged once when schema load fails
101//!
102//! ## Error Handling
103//!
104//! - **Non-fatal errors** (per-packet):
105//!   - Decode failures: Packet dropped, warning logged
106//!   - Validation failures: Packet dropped, warning logged
107//!   - Cache full: New packet dropped, warning logged
108//!
109//! - **Fatal errors** (shutdown triggers):
110//!   - Input channel closed: Actor terminates gracefully
111//!   - Output channel closed: Actor terminates (backpressure failure)
112//!   - Cache channel closed: Actor terminates (dependency failure)
113//!   - Shutdown command received: Graceful termination
114
115use crate::cache::actor::{CacheLookupCommand, CacheResponse};
116use crate::cache::storage::SubscriptionInfo;
117use crate::{
118    ContentId, OTL_CACHE_DROP_REASON_KEY, OTL_CACHE_DROP_REASON_PEER_CACHE_FULL,
119    OTL_CACHE_DROP_REASON_SUBSCRIPTION_CACHE_FULL, OTL_YANG_PUSH_DECODE_ERROR_ID_KEY,
120    OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY, OTL_YANG_PUSH_SUBSCRIPTION_ROUTER_CONTENT_ID_KEY,
121    OTL_YANG_PUSH_SUBSCRIPTION_TARGET_KEY,
122};
123use netgauze_netconf_proto::yang_push::subscription::YangPushModuleVersion;
124use netgauze_netconf_proto::yang_push::types::SubscriptionId;
125use netgauze_udp_notif_pkt::decoded::{UdpNotifPacketDecoded, UdpNotifPayload};
126use netgauze_udp_notif_pkt::notification::{NotificationVariant, SubscriptionStartedModified};
127use netgauze_udp_notif_pkt::raw::UdpNotifPacket;
128use netgauze_udp_notif_service::{OTL_UDP_NOTIF_PUBLISHER_ID_KEY, UdpNotifRequest};
129use rustc_hash::FxHashMap;
130use std::net::{IpAddr, SocketAddr};
131use std::sync::Arc;
132use tokio::sync::mpsc;
133use tracing::{debug, info, trace, warn};
134use yang5::data::{DataFormat, DataOperation, DataParserFlags, DataValidationFlags};
135
136#[derive(Debug)]
137struct CachedSubscription {
138    cached_content_id: Option<ContentId>,
139    subscription_info: SubscriptionInfo,
140    yang_ctx: Option<yang5::context::Context>,
141    cached_packets: Vec<Arc<UdpNotifRequest>>,
142}
143
144#[derive(Debug, Default)]
145struct CachedPeerSubscriptions {
146    subscriptions: FxHashMap<SubscriptionId, CachedSubscription>,
147}
148
149#[derive(Debug, Clone)]
150pub struct ValidationStats {
151    pub messages_received: opentelemetry::metrics::Counter<u64>,
152    pub messages_decoding_success: opentelemetry::metrics::Counter<u64>,
153    pub messages_decoding_fail: opentelemetry::metrics::Counter<u64>,
154    pub cache_request_by_subscription_info: opentelemetry::metrics::Counter<u64>,
155    pub cache_request_by_subscription_id: opentelemetry::metrics::Counter<u64>,
156    pub cached_packets: opentelemetry::metrics::Gauge<u64>,
157    pub cache_drop: opentelemetry::metrics::Counter<u64>,
158    pub cache_drain: opentelemetry::metrics::Counter<u64>,
159    pub cache_yang_ctx_created: opentelemetry::metrics::Counter<u64>,
160    pub cache_yang_ctx_invalid: opentelemetry::metrics::Counter<u64>,
161    pub cache_yang_ctx_empty: opentelemetry::metrics::Counter<u64>,
162    pub validation_success: opentelemetry::metrics::Counter<u64>,
163    pub validation_invalid: opentelemetry::metrics::Counter<u64>,
164    pub validation_malformed: opentelemetry::metrics::Counter<u64>,
165    pub validation_skip: opentelemetry::metrics::Counter<u64>,
166    pub messages_sent: opentelemetry::metrics::Counter<u64>,
167}
168
169impl ValidationStats {
170    pub fn new(meter: opentelemetry::metrics::Meter) -> Self {
171        let messages_received = meter
172            .u64_counter("netgauze.collector.yang_push.validation.messages.received")
173            .with_description(
174                "Number of Yang Push messages received for validation (before decoding)",
175            )
176            .build();
177        let messages_decoding_success = meter
178            .u64_counter("netgauze.collector.yang_push.validation.messages.decode.success")
179            .with_description("Number of Yang Push messages decoded successfully (UDP-Notif payload read successfully)")
180            .build();
181        let messages_decoding_fail = meter
182            .u64_counter("netgauze.collector.yang_push.validation.messages.decode.fail")
183            .with_description("Number of Yang Push messages dropped because of decoding errors (Couldn't read UDP-Notif payload)")
184            .build();
185        let cache_request_by_subscription_info = meter
186            .u64_counter("netgauze.collector.yang_push.validation.messages.cache.requests.by.subscription_info")
187            .with_description("Number of cache requests by subscription info (from subscription-start or subscription-modified messages) to retrieve the schemas for YANG-Push subscriptions")
188            .build();
189        let cache_request_by_subscription_id = meter
190            .u64_counter("netgauze.collector.yang_push.validation.messages.cache.requests.by.subscription_info")
191            .with_description("Number of cache requests by Subscription ID to retrieve the schemas for YANG-Push subscriptions")
192            .build();
193        let cached_packets = meter
194            .u64_gauge("netgauze.collector.yang_push.validation.cache.packets")
195            .with_description("Number of Yang Push message cached")
196            .build();
197        let cache_drop = meter
198            .u64_counter("netgauze.collector.yang_push.validation.cache.drop")
199            .with_description("Number of Yang Push messages dropped because of cache is full")
200            .build();
201        let cache_drain = meter
202            .u64_counter("netgauze.collector.yang_push.validation.cache.drain")
203            .with_description("Number of Yang Push messages popped out of the cache and send is going to the validation step")
204            .build();
205        let cache_yang_ctx_created = meter
206            .u64_counter("netgauze.collector.yang_push.validation.cache.yang.ctx.created")
207            .with_description("Number of libyang validation context that are successfully created")
208            .build();
209        let cache_yang_ctx_invalid = meter
210            .u64_counter("netgauze.collector.yang_push.validation.cache.yang.ctx.invalid")
211            .with_description(
212                "Number of libyang validation context that are invalid (e.g., missing schema)",
213            )
214            .build();
215        let cache_yang_ctx_empty = meter
216            .u64_counter("netgauze.collector.yang_push.validation.cache.yang.ctx.empty")
217            .with_description("Number of libyang validation context that are empty (e.g., schema loading from the router failed)")
218            .build();
219        let validation_malformed = meter
220            .u64_counter("netgauze.collector.yang_push.validation.malformed")
221            .with_description(
222                "Number of Yang Push messages dropped because they are malformed; e.g., missing subscription info",
223            )
224            .build();
225        let validation_success = meter
226            .u64_counter("netgauze.collector.yang_push.validation.success")
227            .with_description("Number of Yang Push messages successfully validated")
228            .build();
229        let validation_invalid = meter
230            .u64_counter("netgauze.collector.yang_push.validation.invalid")
231            .with_description("Number of Yang Push messages dropped because of validation errors")
232            .build();
233        let validation_skip = meter
234            .u64_counter("netgauze.collector.yang_push.validation.skipped")
235            .with_description("Number of Yang Push skipped the validation step because the subscription is not found in the cache")
236            .build();
237        let messages_sent = meter
238            .u64_counter("netgauze.collector.yang_push.validation.messages.sent")
239            .with_description("Number of Telemetry Messages successfully sent upstream")
240            .build();
241        Self {
242            messages_received,
243            messages_decoding_success,
244            messages_decoding_fail,
245            cache_request_by_subscription_info,
246            cache_request_by_subscription_id,
247            cached_packets,
248            cache_drop,
249            cache_drain,
250            cache_yang_ctx_created,
251            cache_yang_ctx_invalid,
252            cache_yang_ctx_empty,
253            validation_success,
254            validation_invalid,
255            validation_malformed,
256            validation_skip,
257            messages_sent,
258        }
259    }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, strum_macros::Display)]
263pub enum ValidationActorError {
264    #[strum(serialize = "Failed to send cache lookup command")]
265    CacheLookupSendError,
266    #[strum(serialize = "Failed to receive cache response")]
267    CacheResponseReceiveError,
268    #[strum(serialize = "Failed to send the decoded UDP-Notif packet")]
269    SendError,
270}
271
272impl std::error::Error for ValidationActorError {}
273
274#[derive(Debug, Clone, Copy)]
275enum ValidationActorCommand {
276    Shutdown,
277}
278
279struct ValidationActor {
280    max_cached_packets_per_peer: usize,
281    max_cached_packets_per_subscription: usize,
282    peer_cache: FxHashMap<IpAddr, CachedPeerSubscriptions>,
283    cmd_rx: mpsc::Receiver<ValidationActorCommand>,
284    rx: async_channel::Receiver<Arc<UdpNotifRequest>>,
285    tx: async_channel::Sender<(Option<ContentId>, SubscriptionInfo, UdpNotifPacketDecoded)>,
286    cache_cmd_tx: async_channel::Sender<CacheLookupCommand>,
287    cache_tx: async_channel::Sender<CacheResponse>,
288    cache_rx: async_channel::Receiver<CacheResponse>,
289    stats: ValidationStats,
290}
291
292impl ValidationActor {
293    /// Check if the subscription is different from the existing one in the
294    /// cache.
295    ///
296    /// If it is different, remove the existing one from the cache to allow a
297    /// new request to the caching actor.
298    fn check_subscription_new(&mut self, peer: SocketAddr, subscription_info: &SubscriptionInfo) {
299        if let Some(cached_peer_subscriptions) = self.peer_cache.get_mut(&peer.ip()) {
300            let is_different = cached_peer_subscriptions
301                .subscriptions
302                .get(&subscription_info.id())
303                .map(|x| x.subscription_info != *subscription_info)
304                .unwrap_or(true);
305            if is_different {
306                trace!(
307                    peer=%peer,
308                    subscription_id=subscription_info.id(),
309                    router_content_id=subscription_info.content_id(),
310                    target=%subscription_info.target(),
311                    "Subscription changed, removing from cache to allow a new fetch schemas request"
312                );
313                cached_peer_subscriptions
314                    .subscriptions
315                    .remove(&subscription_info.id());
316            }
317            // clear peer if there are no subscriptions left
318            if cached_peer_subscriptions.subscriptions.is_empty() {
319                self.peer_cache.remove(&peer.ip());
320            }
321        }
322    }
323
324    /// Get the subscription info from the cache or from the SubscriptionStarted
325    /// notification, and the cached content id if it's found in the cache.
326    ///
327    /// If the notification is a SubscriptionStarted, create a new
328    /// SubscriptionInfo and return it. If the notification is not a
329    /// SubscriptionStarted, look up the subscription info in the cache.
330    fn get_subscription_info(
331        &mut self,
332        peer: SocketAddr,
333        collector: SocketAddr,
334        interface: Option<String>,
335        decoded: &UdpNotifPacketDecoded,
336    ) -> Option<(SubscriptionInfo, Option<Option<String>>)> {
337        let message_id = decoded.message_id();
338        let publisher_id = decoded.publisher_id();
339        let notif_contents = if let Some(notif) = decoded.payload().notification_contents() {
340            notif
341        } else {
342            warn!(
343                peer=%peer,
344                message_id,
345                publisher_id,
346                "Received UDP-Notif payload without a notifications content, dropping packet"
347            );
348            return None;
349        };
350
351        let subscription_info = if let NotificationVariant::SubscriptionStarted(
352            subscription_started,
353        )
354        | NotificationVariant::SubscriptionModified(
355            subscription_started,
356        ) = notif_contents
357        {
358            let subscription_info = if let Some(subscription_info) = self.build_subscription_info(
359                peer,
360                collector,
361                interface,
362                message_id,
363                publisher_id,
364                subscription_started,
365            ) {
366                subscription_info
367            } else {
368                warn!(
369                    peer=%peer,
370                    message_id,
371                    publisher_id,
372                    notifification_type=%notif_contents.notification_type(),
373                    "Received UDP-Notif of subscription started/modified payload without subscription info, dropping packet"
374                );
375                return None;
376            };
377            self.check_subscription_new(peer, &subscription_info);
378            Some(subscription_info)
379        } else {
380            self.peer_cache.get(&peer.ip()).and_then(
381                |cached_peer_subscriptions: &CachedPeerSubscriptions| {
382                    cached_peer_subscriptions
383                        .subscriptions
384                        .get(&notif_contents.subscription_id())
385                        .map(|x| x.subscription_info.clone())
386                },
387            )
388        };
389        if let Some(subscription_info) = subscription_info {
390            let cached_content_id = self
391                .peer_cache
392                .get(&peer.ip())
393                .and_then(|cached_peer_subscriptions: &CachedPeerSubscriptions| {
394                    cached_peer_subscriptions
395                        .subscriptions
396                        .get(&notif_contents.subscription_id())
397                })
398                .filter(|x| x.subscription_info == subscription_info)
399                .map(|x| x.cached_content_id.clone());
400
401            Some((subscription_info, cached_content_id))
402        } else {
403            None
404        }
405    }
406
407    fn cache_packet(
408        &mut self,
409        subscription_info: SubscriptionInfo,
410        message: Arc<UdpNotifRequest>,
411    ) -> bool {
412        let peer = message.peer_address();
413        let packet = message.packet();
414        let message_id = packet.message_id();
415        let publisher_id = packet.publisher_id();
416        let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
417        let subscription_id = subscription_info.id();
418        let peer_cache = self.peer_cache.entry(peer.ip()).or_default();
419        let total_cached_packets = peer_cache
420            .subscriptions
421            .values()
422            .map(|x| x.cached_packets.len())
423            .sum::<usize>();
424        let subscription_cache =
425            peer_cache
426                .subscriptions
427                .entry(subscription_id)
428                .or_insert(CachedSubscription {
429                    cached_content_id: None,
430                    subscription_info: subscription_info.clone(),
431                    yang_ctx: None,
432                    cached_packets: Vec::new(),
433                });
434        if subscription_cache.cached_packets.len() > self.max_cached_packets_per_subscription {
435            // drop the new packet, since the cache is full
436            warn!(
437                peer=%peer,
438                message_id,
439                publisher_id,
440                subscription_id,
441                subscription_target=%subscription_info.target(),
442                router_content_id=subscription_info.content_id(),
443                "Cache full for subscription, dropping new packet"
444            );
445            peer_tags.push(opentelemetry::KeyValue::new(
446                OTL_CACHE_DROP_REASON_KEY,
447                OTL_CACHE_DROP_REASON_SUBSCRIPTION_CACHE_FULL,
448            ));
449            self.stats.cache_drop.add(1, &peer_tags);
450            return false;
451        }
452        if total_cached_packets > self.max_cached_packets_per_peer {
453            warn!(
454                peer=%peer,
455                message_id,
456                publisher_id,
457                subscription_id,
458                subscription_target=%subscription_info.target(),
459                router_content_id=subscription_info.content_id(),
460                "Cache full for peer, dropping new packet");
461            peer_tags.push(opentelemetry::KeyValue::new(
462                OTL_CACHE_DROP_REASON_KEY,
463                OTL_CACHE_DROP_REASON_PEER_CACHE_FULL,
464            ));
465            self.stats.cache_drop.add(1, &peer_tags);
466            return false;
467        }
468        trace!(
469            peer=%peer,
470            message_id,
471            publisher_id,
472            subscription_id,
473            subscription_target=%subscription_info.target(),
474            router_content_id=subscription_info.content_id(),
475            "Cached UDP-Notif packet"
476        );
477        self.stats
478            .cached_packets
479            .record(total_cached_packets as u64, &peer_tags);
480        subscription_cache.cached_packets.push(message);
481        true
482    }
483
484    fn peer_tags_from_packet(
485        peer: SocketAddr,
486        packet: &UdpNotifPacket,
487    ) -> Vec<opentelemetry::KeyValue> {
488        let publisher_id = packet.publisher_id();
489        Vec::from([
490            opentelemetry::KeyValue::new("network.peer.address", format!("{}", peer.ip())),
491            opentelemetry::KeyValue::new(
492                "network.peer.port",
493                opentelemetry::Value::I64(peer.port().into()),
494            ),
495            opentelemetry::KeyValue::new(
496                OTL_UDP_NOTIF_PUBLISHER_ID_KEY,
497                opentelemetry::Value::I64(publisher_id.into()),
498            ),
499        ])
500    }
501
502    fn extend_peer_targs_with_subscription_info(
503        subscription_info: &SubscriptionInfo,
504        peer_tags: &mut Vec<opentelemetry::KeyValue>,
505    ) {
506        peer_tags.push(opentelemetry::KeyValue::new(
507            OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
508            opentelemetry::Value::I64(subscription_info.id().into()),
509        ));
510        peer_tags.push(opentelemetry::KeyValue::new(
511            OTL_YANG_PUSH_SUBSCRIPTION_TARGET_KEY,
512            format!("{}", subscription_info.target()),
513        ));
514        peer_tags.push(opentelemetry::KeyValue::new(
515            OTL_YANG_PUSH_SUBSCRIPTION_ROUTER_CONTENT_ID_KEY,
516            subscription_info.content_id().to_string(),
517        ));
518    }
519
520    fn decode_message(
521        &mut self,
522        peer: SocketAddr,
523        packet: &UdpNotifPacket,
524    ) -> Result<UdpNotifPacketDecoded, ()> {
525        let message_id = packet.message_id();
526        let publisher_id = packet.publisher_id();
527        let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
528
529        // Decode the UDP-Notif packet to get subscription ID and payload information
530        match UdpNotifPacketDecoded::try_from(packet) {
531            Ok(decoded) => {
532                let notif_contents = decoded.payload().notification_contents();
533                if let Some(notif_contents) = notif_contents {
534                    peer_tags.push(opentelemetry::KeyValue::new(
535                        OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
536                        opentelemetry::Value::I64(notif_contents.subscription_id().into()),
537                    ));
538                }
539                if tracing::enabled!(tracing::Level::TRACE) {
540                    let notification_type = decoded
541                        .notification_type()
542                        .map(|x| x.to_string())
543                        .unwrap_or("UNKNOWN".to_string());
544                    trace!(
545                        peer=%peer,
546                        message_id,
547                        publisher_id,
548                        notification_type,
549                        "Decoded UDP-Notif payload, starting the validation step"
550                    );
551                }
552                self.stats.messages_received.add(1, &peer_tags);
553                Ok(decoded)
554            }
555            Err(err) => {
556                warn!(
557                    peer=%peer,
558                    message_id,
559                    publisher_id,
560                    error=%err,
561                    "Failed to decode UDP-Notif payload, dropping packet"
562                );
563                peer_tags.push(opentelemetry::KeyValue::new(
564                    OTL_YANG_PUSH_DECODE_ERROR_ID_KEY,
565                    format!("{err}"),
566                ));
567                self.stats.messages_received.add(1, &peer_tags);
568                self.stats.messages_decoding_fail.add(1, &peer_tags);
569                Err(())
570            }
571        }
572    }
573
574    async fn process_udp_notif_msg(
575        &mut self,
576        message: Arc<UdpNotifRequest>,
577    ) -> Result<(), ValidationActorError> {
578        let peer = message.peer_address();
579        let packet = message.packet();
580        let decoded = match self.decode_message(peer, packet) {
581            Ok(decoded) => decoded,
582            // Decoding errors are logged in the [Self::decode_message], and packets are dropped
583            // here
584            Err(_) => return Ok(()),
585        };
586        let notification_type = decoded
587            .notification_type()
588            .map(|x| x.to_string())
589            .unwrap_or("UNKNOWN".to_string());
590        let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
591        let message_id = decoded.message_id();
592        let publisher_id = decoded.publisher_id();
593        let is_legacy = matches!(decoded.payload(), UdpNotifPayload::NotificationLegacy(_));
594        let extract_sub_info = self
595            .extract_subscription_info(Arc::clone(&message), peer, &decoded)
596            .await?;
597        let subscription_info = if let Some(subscription_info) = extract_sub_info {
598            subscription_info
599        } else {
600            return Ok(());
601        };
602        Self::extend_peer_targs_with_subscription_info(&subscription_info, &mut peer_tags);
603        let peer_cache = self.peer_cache.entry(peer.ip()).or_default();
604        let subscription_cache = peer_cache
605            .subscriptions
606            .entry(subscription_info.id())
607            .or_insert(CachedSubscription {
608                cached_content_id: None,
609                subscription_info: subscription_info.clone(),
610                yang_ctx: None,
611                cached_packets: Vec::new(),
612            });
613        let cached_content_id = if let Some(cached_content_id) =
614            subscription_cache.cached_content_id.clone()
615            && let Some(yang_ctx) = subscription_cache.yang_ctx.as_ref()
616            && !subscription_info.is_empty()
617        {
618            let validation_result = Self::validate_message(
619                packet,
620                peer,
621                &subscription_info,
622                cached_content_id.clone(),
623                &notification_type,
624                yang_ctx,
625                is_legacy,
626            );
627            // logging of error is handled in the [Self::validate_message]
628            if validation_result.is_err() {
629                self.stats.validation_invalid.add(1, &peer_tags);
630                return Ok(());
631            }
632            Some(cached_content_id)
633        } else {
634            trace!(
635                peer=%peer,
636                message_id,
637                publisher_id,
638                subscription_id=subscription_info.id(),
639                router_content_id=subscription_info.content_id(),
640                target=%subscription_info.target(),
641                notification_type,
642                "No YANG schemas found, skipping validation step",
643            );
644            self.stats.validation_skip.add(1, &peer_tags);
645            None
646        };
647
648        self.tx
649            .send((
650                cached_content_id.clone(),
651                subscription_info.clone(),
652                decoded,
653            ))
654            .await
655            .map_err(|_| {
656                warn!(
657                    peer=%peer,
658                    message_id,
659                    publisher_id,
660                    subscription_id=subscription_info.id(),
661                    router_content_id=subscription_info.content_id(),
662                    target=%subscription_info.target(),
663                    cached_content_id=cached_content_id.clone().unwrap_or_default(),
664                    notification_type,
665                    "Failed to send UDP-Notif message for the next actor to process"
666                );
667                ValidationActorError::SendError
668            })?;
669        self.stats.messages_sent.add(1, &peer_tags);
670        trace!(
671            peer=%peer,
672            message_id,
673            publisher_id,
674            subscription_id=subscription_info.id(),
675            router_content_id=subscription_info.content_id(),
676            target=%subscription_info.target(),
677            cached_content_id=cached_content_id.unwrap_or_default(),
678            notification_type,
679            "Successfully send UDP-Notif message for the next actor to process"
680        );
681        Ok(())
682    }
683
684    fn validate_message(
685        packet: &UdpNotifPacket,
686        peer: SocketAddr,
687        subscription_info: &SubscriptionInfo,
688        cached_content_id: ContentId,
689        notification_type: &String,
690        yang_ctx: &yang5::context::Context,
691        is_legacy: bool,
692    ) -> Result<(), yang5::Error> {
693        let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
694        Self::extend_peer_targs_with_subscription_info(subscription_info, &mut peer_tags);
695        let message_id = packet.message_id();
696        let publisher_id = packet.publisher_id();
697
698        if !is_legacy {
699            let validation_result = yang5::data::DataTree::parse_string(
700                yang_ctx,
701                packet.payload(),
702                DataFormat::JSON,
703                DataParserFlags::STRICT,
704                DataValidationFlags::PRESENT,
705            );
706            if let Err(err) = validation_result {
707                let v = packet.payload().clone();
708                let packet_payload =
709                    str::from_utf8(v.as_ref()).unwrap_or("unserializable packet payload");
710                warn!(
711                    peer=%peer,
712                    message_id,
713                    publisher_id,
714                    subscription_id=subscription_info.id(),
715                    router_content_id=subscription_info.content_id(),
716                    target=%subscription_info.target(),
717                    cached_content_id,
718                    notification_type,
719                    error=%err,
720                    packet=packet_payload,
721                    "Failed to validate UDP-Notif payload using draft-ietf-netconf-notif-envelope, dropping packet"
722                );
723                return Err(err);
724            }
725            trace!(
726                peer=%peer,
727                message_id,
728                publisher_id,
729                subscription_id=subscription_info.id(),
730                router_content_id=subscription_info.content_id(),
731                target=%subscription_info.target(),
732                notification_type,
733                cached_content_id,
734                "Successfully validated YANG-Push message using draft-ietf-netconf-notif-envelope",
735            );
736            Ok(())
737        } else {
738            let validation_result = yang5::data::DataTree::parse_op_string(
739                yang_ctx,
740                packet.payload(),
741                DataFormat::JSON,
742                DataParserFlags::STRICT,
743                DataOperation::NotificationYang,
744            );
745            if let Err(err) = validation_result {
746                warn!(
747                    peer=%peer,
748                    subscription_id=subscription_info.id(),
749                    router_content_id=subscription_info.content_id(),
750                    target=%subscription_info.target(),
751                    cached_content_id,
752                    notification_type,
753                    error=%err, "Failed to validate legacy UDP-Notif payload, dropping packet");
754                return Err(err);
755            }
756            trace!(
757                peer=%peer,
758                message_id,
759                publisher_id,
760                subscription_id=subscription_info.id(),
761                router_content_id=subscription_info.content_id(),
762                target=%subscription_info.target(),
763                notification_type,
764                cached_content_id,
765                "Successfully validated YANG-Push message using legacy UDP-Notif payload",
766            );
767            Ok(())
768        }
769    }
770
771    /// Get the subscription info from the message, if not present cache and
772    /// send a cache request and return none for the subscription info
773    async fn extract_subscription_info(
774        &mut self,
775        message: Arc<UdpNotifRequest>,
776        peer: SocketAddr,
777        decoded: &UdpNotifPacketDecoded,
778    ) -> Result<Option<SubscriptionInfo>, ValidationActorError> {
779        let collector = message.collector_address();
780        let interface = message.collector_interface();
781        let packet = message.packet();
782        let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
783        let message_id = decoded.message_id();
784        let publisher_id = decoded.publisher_id();
785        let notification_type = decoded
786            .notification_type()
787            .map(|x| x.to_string())
788            .unwrap_or("UNKNOWN".to_string());
789
790        match self.get_subscription_info(peer, collector, interface.map(String::from), decoded) {
791            Some((subscription_info, cached_content_id)) => {
792                Self::extend_peer_targs_with_subscription_info(&subscription_info, &mut peer_tags);
793                if cached_content_id.is_some() {
794                    return Ok(Some(subscription_info));
795                }
796                debug!(
797                    peer=%peer,
798                    message_id,
799                    publisher_id,
800                    subscription_id=subscription_info.id(),
801                    router_content_id=subscription_info.content_id(),
802                    subscription_target=%subscription_info.target(),
803                    notification_type,
804                    "Received new subscription sending lookup by subscription info request to the cache"
805                );
806                self.stats
807                    .cache_request_by_subscription_info
808                    .add(1, &peer_tags);
809                self.cache_cmd_tx
810                    .send(CacheLookupCommand::LookupBySubscriptionInfo(
811                        subscription_info.clone(),
812                        self.cache_tx.clone(),
813                    ))
814                    .await
815                    .map_err(|error| {
816                        warn!(
817                            message_id,
818                            publisher_id,
819                            subscription_id=subscription_info.id(),
820                            router_content_id=subscription_info.content_id(),
821                            subscription_target=%subscription_info.target(),
822                            notification_type,
823                            error=%error,
824                            "Error sending lookup by subscription info request to the cache"
825                        );
826                        ValidationActorError::CacheLookupSendError
827                    })?;
828                self.cache_packet(subscription_info.clone(), message);
829                Ok(None)
830            }
831            None => {
832                let subscription_id = decoded
833                    .payload()
834                    .notification_contents()
835                    .map(|x| x.subscription_id());
836                if let Some(subscription_id) = subscription_id {
837                    debug!(
838                        peer=%peer,
839                        message_id,
840                        publisher_id,
841                        subscription_id,
842                        notification_type,
843                        "Received UDP-Notif packet without subscription info, \
844                        caching the packet and looking up subscription info in cache");
845                    self.stats
846                        .cache_request_by_subscription_info
847                        .add(1, &peer_tags);
848                    let subscription_info = SubscriptionInfo::new_empty(
849                        collector,
850                        interface.map(String::from),
851                        peer,
852                        subscription_id,
853                    );
854                    self.cache_cmd_tx
855                        .send(CacheLookupCommand::LookupBySubscriptionId {
856                            collector,
857                            interface: interface.map(String::from),
858                            peer,
859                            subscription_id,
860                            tx: self.cache_tx.clone(),
861                        })
862                        .await
863                        .map_err(|_| ValidationActorError::CacheLookupSendError)?;
864                    self.cache_packet(subscription_info.clone(), message);
865                    return Ok(None);
866                }
867                warn!(
868                    peer=%peer,
869                    message_id,
870                    publisher_id,
871                    notification_type,
872                    "Received UDP-Notif packet without subscription info nor subscription ID, dropping packet"
873                );
874                self.stats.validation_invalid.add(1, &peer_tags);
875                Ok(None)
876            }
877        }
878    }
879
880    async fn process_cache_response(
881        &mut self,
882        response: CacheResponse,
883    ) -> Result<(), ValidationActorError> {
884        let (cached_content_id, subscription_info, yang_lib_ref) = response.into();
885        let mut otl_tags = Vec::from([
886            opentelemetry::KeyValue::new(
887                "network.peer.address",
888                format!("{}", subscription_info.peer().ip()),
889            ),
890            opentelemetry::KeyValue::new(
891                "network.peer.port",
892                opentelemetry::Value::I64(subscription_info.peer().port().into()),
893            ),
894        ]);
895        Self::extend_peer_targs_with_subscription_info(&subscription_info, &mut otl_tags);
896        let peer_cache = if let Some(peer_cache) =
897            self.peer_cache.get_mut(&subscription_info.peer().ip())
898        {
899            peer_cache
900        } else {
901            warn!(
902                peer=%subscription_info.peer(),
903                subscription_id=subscription_info.id(),
904                router_content_id=subscription_info.content_id(),
905                target=%subscription_info.target(),
906                cached_content_id,
907                "Received cache response for subscription from peer that is not in the cache, ignoring the response"
908            );
909            return Ok(());
910        };
911
912        let subscription_cache = if let Some(subscription_cache) =
913            peer_cache.subscriptions.get_mut(&subscription_info.id())
914        {
915            subscription_cache
916        } else {
917            warn!(
918                peer=%subscription_info.peer(),
919                subscription_id=subscription_info.id(),
920                router_content_id=subscription_info.content_id(),
921                target=%subscription_info.target(),
922                cached_content_id,
923                "Received cache response for subscription that is not in the cache, ignoring the response");
924            return Ok(());
925        };
926
927        // Update subscription info in the cache
928        subscription_cache.subscription_info = subscription_info.clone();
929        if let Some(yang_lib_ref) = yang_lib_ref {
930            let search_dir = yang_lib_ref.search_dir();
931            let yang_ctx_result = yang5::context::Context::new_from_yang_library_file(
932                &yang_lib_ref.yang_library_path(),
933                DataFormat::XML,
934                &search_dir.as_path(),
935                yang5::context::ContextFlags::empty(),
936            );
937            let yang_ctx = match yang_ctx_result {
938                Ok(yang_ctx) => {
939                    self.stats.cache_yang_ctx_created.add(1, &otl_tags);
940                    Some(yang_ctx)
941                }
942                Err(err) => {
943                    self.stats.cache_yang_ctx_invalid.add(1, &otl_tags);
944                    warn!(
945                        peer=%subscription_info.peer(),
946                        subscription_id=subscription_info.id(),
947                        router_content_id=subscription_info.content_id(),
948                        cached_content_id=yang_lib_ref.content_id(),
949                        yang_library_path=%yang_lib_ref.yang_library_path().display(),
950                        search_dir=%search_dir.display(),
951                        error=%err,
952                        "Failed to create YANG context, disabling YANG validation for this subscription");
953                    None
954                }
955            };
956            subscription_cache.cached_content_id = cached_content_id.clone();
957            subscription_cache.yang_ctx = yang_ctx;
958        } else {
959            self.stats.cache_yang_ctx_empty.add(1, &otl_tags);
960            subscription_cache.cached_content_id = None;
961            subscription_cache.yang_ctx = None;
962        }
963        let cached_packets = std::mem::take(&mut subscription_cache.cached_packets);
964        for message in cached_packets {
965            let peer = message.peer_address();
966            let packet = message.packet();
967            let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
968            Self::extend_peer_targs_with_subscription_info(&subscription_info, &mut peer_tags);
969            self.stats.cache_drain.add(1, &peer_tags);
970            trace!(
971                peer=%peer,
972                message_id=packet.message_id(),
973                publisher_id=packet.publisher_id(),
974                subscription_id=subscription_info.id(),
975                router_content_id=subscription_info.content_id(),
976                subscription_target=%subscription_info.target(),
977                cached_content_id,
978                "Packet popped out of the cache and being processed by the validation step"
979            );
980            self.process_udp_notif_msg(message).await?;
981        }
982        Ok(())
983    }
984
985    fn build_subscription_info(
986        &self,
987        peer: SocketAddr,
988        collector: SocketAddr,
989        interface: Option<String>,
990        message_id: u32,
991        publisher_id: u32,
992        sub_started: &SubscriptionStartedModified,
993    ) -> Option<SubscriptionInfo> {
994        let modules = match sub_started.module_version() {
995            Some(modules) => {
996                let mut modules = modules.to_vec();
997                modules.push(YangPushModuleVersion::new(
998                    "ietf-subscribed-notifications".into(),
999                    None,
1000                    None,
1001                ));
1002                modules.into_boxed_slice()
1003            }
1004            None => {
1005                warn!(
1006                    peer=%peer,
1007                    message_id,
1008                    publisher_id,
1009                    subscription_id=sub_started.id(),
1010                    subscription_target=%sub_started.target(),
1011                    "SubscriptionStarted missing module version"
1012                );
1013                return None;
1014            }
1015        };
1016
1017        Some(SubscriptionInfo::new(
1018            collector,
1019            interface,
1020            peer,
1021            sub_started.id(),
1022            sub_started.target().clone(),
1023            sub_started.stop_time().cloned(),
1024            sub_started.transport().cloned(),
1025            sub_started.encoding().cloned(),
1026            sub_started.purpose().map(|x| x.into()),
1027            sub_started.update_trigger().cloned(),
1028            modules,
1029            sub_started
1030                .yang_library_content_id()
1031                .map(|x| x.to_string())
1032                .unwrap_or_default(),
1033        ))
1034    }
1035
1036    async fn run(mut self) -> Result<String, ValidationActorError> {
1037        info!("Starting Yang-Push validation actor");
1038        loop {
1039            tokio::select! {
1040                biased;
1041                cmd = self.cmd_rx.recv() => {
1042                    return match cmd {
1043                        Some(ValidationActorCommand::Shutdown) => {
1044                            info!("Shutting down Yang Push validation actor");
1045                            Ok("Enrichment shutdown successfully".to_string())
1046                        }
1047                        None => {
1048                            let msg = "Yang Push validation actor terminated due to command channel closing";
1049                            warn!(msg);
1050                            Ok(msg.to_string())
1051                        }
1052                    }
1053                }
1054                msg = self.rx.recv() => {
1055                    match msg {
1056                        Ok(msg) => {
1057                            if let Err(err) = self.process_udp_notif_msg(msg).await {
1058                                let err_msg = "Yang Push validation actor UDP-Notif processing unrecoverable error, shutting down";
1059                                warn!(error=%err, err_msg);
1060                                return Ok(err_msg.to_string());
1061                            }
1062                        }
1063                        Err(error) => {
1064                            let err_msg = "Yang Push validation actor UDP Notif receiver channel closed unexpectedly, shutting down";
1065                            warn!(error=%error, err_msg);
1066                            return Ok(err_msg.to_string());
1067                        }
1068                    }
1069                }
1070                msg = self.cache_rx.recv() => {
1071                    match msg {
1072                        Ok(response) => {
1073                            if let Err(err) = self.process_cache_response(response).await {
1074                                let err_msg = "Yang Push validation actor cache response processing unrecoverable error, shutting down";
1075                                warn!(error=%err, err_msg);
1076                                return Ok(err_msg.to_string());
1077                            }
1078                        }
1079                        Err(error) => {
1080                            let err_msg = "Yang Push validation actor cache receiver channel closed unexpectedly, shutting down";
1081                            warn!(error=%error, err_msg);
1082                            return Ok(err_msg.to_string());
1083                        }
1084                    }
1085                }
1086            }
1087        }
1088    }
1089}
1090
1091#[derive(Debug, strum_macros::Display)]
1092pub enum ValidationActorHandleError {
1093    #[strum(serialize = "Failed to send command to actor")]
1094    SendErr,
1095}
1096
1097impl std::error::Error for ValidationActorHandleError {}
1098
1099#[derive(Debug, Clone)]
1100pub struct ValidationActorHandle {
1101    cmd_tx: mpsc::Sender<ValidationActorCommand>,
1102}
1103
1104impl ValidationActorHandle {
1105    pub fn new(
1106        buffer_size: usize,
1107        max_cached_packets_per_peer: usize,
1108        max_cached_packets_per_subscription: usize,
1109        rx: async_channel::Receiver<Arc<UdpNotifRequest>>,
1110        tx: async_channel::Sender<(Option<ContentId>, SubscriptionInfo, UdpNotifPacketDecoded)>,
1111        cache_cmd_tx: async_channel::Sender<CacheLookupCommand>,
1112        stats: either::Either<opentelemetry::metrics::Meter, ValidationStats>,
1113    ) -> Result<
1114        (
1115            tokio::task::JoinHandle<Result<String, ValidationActorError>>,
1116            Self,
1117        ),
1118        ValidationActorHandleError,
1119    > {
1120        let (cmd_tx, cmd_rx) = mpsc::channel(100);
1121        let (cache_tx, cache_rx) = async_channel::bounded(buffer_size);
1122        let stats = match stats {
1123            either::Either::Left(meter) => ValidationStats::new(meter),
1124            either::Either::Right(stats) => stats,
1125        };
1126        let actor = ValidationActor {
1127            max_cached_packets_per_peer,
1128            max_cached_packets_per_subscription,
1129            peer_cache: FxHashMap::default(),
1130            cmd_rx,
1131            rx,
1132            tx,
1133            cache_cmd_tx,
1134            cache_tx,
1135            cache_rx,
1136            stats,
1137        };
1138        let handle = ValidationActorHandle { cmd_tx };
1139        let join_handle = tokio::spawn(async move { actor.run().await });
1140        Ok((join_handle, handle))
1141    }
1142
1143    pub async fn shutdown(&self) -> Result<(), ValidationActorHandleError> {
1144        self.cmd_tx
1145            .send(ValidationActorCommand::Shutdown)
1146            .await
1147            .map_err(|_| ValidationActorHandleError::SendErr)
1148    }
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153    use super::*;
1154    use crate::cache::actor::tests::setup_actor_with_empty_cache;
1155    use bytes::Bytes;
1156    use netgauze_udp_notif_pkt::raw::MediaType;
1157    use std::collections::HashMap;
1158    use std::time::Duration;
1159
1160    #[tokio::test]
1161    #[tracing_test::traced_test]
1162    async fn test_validation_actor_schema_fetched() {
1163        // Setup caching actor
1164        let (caching_join_handle, caching_handle, subscription_info, fetcher_count) =
1165            setup_actor_with_empty_cache();
1166        {
1167            let hits_counts = fetcher_count
1168                .lock()
1169                .expect("Failed to lock fetcher counts")
1170                .clone();
1171            assert_eq!(hits_counts.len(), 0);
1172        }
1173
1174        // Setup channels
1175        let (udp_notif_tx, udp_notif_rx) = async_channel::bounded(100);
1176        let (validated_tx, validated_rx) = async_channel::bounded(100);
1177
1178        // Spawn validation actor
1179        let (_join_handle, handle) = ValidationActorHandle::new(
1180            100,
1181            1000,
1182            100,
1183            udp_notif_rx,
1184            validated_tx,
1185            caching_handle.request_tx(),
1186            either::Right(ValidationStats::new(opentelemetry::global::meter(
1187                "test_meter",
1188            ))),
1189        )
1190        .expect("Failed to spawn validation actor");
1191
1192        // Create a test peer address
1193        let peer = subscription_info.peer();
1194        let payload = serde_json::json!(
1195            {
1196                "ietf-yp-notification:envelope": {
1197                    "event-time": "2025-09-23T14:12:16.024Z",
1198                    "hostname": "ipf-zbl1327-r-daisy-48",
1199                    "sequence-number": 0,
1200                    "contents": {
1201                        "ietf-subscribed-notifications:subscription-started": {
1202                            "id": 1,
1203                            "ietf-yang-push:datastore": "ietf-datastores:operational",
1204                            "ietf-yang-push:datastore-xpath-filter": "/ietf-interfaces:interfaces",
1205                            "transport": "ietf-udp-notif-transport:udp-notif",
1206                            "encoding": "encode-json",
1207                            "purpose": "test subscription",
1208                            "ietf-distributed-notif:message-publisher-id": [
1209                                16843789
1210                            ],
1211                            "ietf-yang-push-revision:module-version": [
1212                                {
1213                                    "name": "ietf-interfaces",
1214                                    "revision": "2018-02-20"
1215                                }
1216                            ],
1217                            "ietf-yang-push-revision:yang-library-content-id": "test-content-id-1",
1218                            "ietf-yang-push:periodic": {
1219                                "period": 6000
1220                            }
1221                        }
1222                    }
1223                }
1224            }
1225        );
1226        let bytes = serde_json::to_vec(&payload).unwrap();
1227        let subscription_started_packet = UdpNotifPacket::new(
1228            MediaType::YangDataJson,
1229            10,
1230            1,
1231            HashMap::new(),
1232            Bytes::from(bytes),
1233        );
1234
1235        // Send SubscriptionStarted packet
1236        udp_notif_tx
1237            .send(Arc::new(UdpNotifRequest::new(
1238                SocketAddr::from(([127, 0, 0, 1], 10000)),
1239                None,
1240                peer,
1241                subscription_started_packet,
1242            )))
1243            .await
1244            .unwrap();
1245
1246        // Allow actor to process
1247        tokio::time::sleep(Duration::from_millis(50)).await;
1248
1249        // Verify packet is validated
1250        let (content_id, sub_info, _validated) =
1251            tokio::time::timeout(Duration::from_secs(1), validated_rx.recv())
1252                .await
1253                .expect("timeout waiting for response")
1254                .unwrap();
1255        assert!(content_id.is_some());
1256        assert!(!sub_info.is_empty());
1257
1258        // check fetcher was called
1259        {
1260            let hits_counts = fetcher_count
1261                .lock()
1262                .expect("Failed to lock fetcher counts")
1263                .clone();
1264            assert_eq!(hits_counts.len(), 1);
1265        }
1266
1267        // Shutdown actor
1268        handle.shutdown().await.unwrap();
1269        caching_handle.shutdown().await.unwrap();
1270        caching_join_handle.await.unwrap().unwrap();
1271    }
1272
1273    #[tokio::test]
1274    #[tracing_test::traced_test]
1275    async fn test_validation_actor_schema_not_found() {
1276        // Setup caching actor
1277        let (caching_join_handle, caching_handle, subscription_info, fetcher_count) =
1278            setup_actor_with_empty_cache();
1279        {
1280            let hits_counts = fetcher_count
1281                .lock()
1282                .expect("Failed to lock fetcher counts")
1283                .clone();
1284            assert_eq!(hits_counts.len(), 0);
1285        }
1286
1287        // Setup channels
1288        let (udp_notif_tx, udp_notif_rx) = async_channel::bounded(100);
1289        let (validated_tx, validated_rx) = async_channel::bounded(100);
1290
1291        // Spawn validation actor
1292        let (_join_handle, handle) = ValidationActorHandle::new(
1293            100,
1294            1000,
1295            100,
1296            udp_notif_rx,
1297            validated_tx,
1298            caching_handle.request_tx(),
1299            either::Right(ValidationStats::new(opentelemetry::global::meter(
1300                "test_meter",
1301            ))),
1302        )
1303        .expect("Failed to spawn validation actor");
1304
1305        // Create a test peer address
1306        let peer = subscription_info.peer();
1307        let payload = serde_json::json!(
1308            {
1309                "ietf-yp-notification:envelope": {
1310                    "event-time": "2025-09-23T14:12:16.024Z",
1311                    "hostname": "ipf-zbl1327-r-daisy-48",
1312                    "sequence-number": 0,
1313                    "contents": {
1314                        "ietf-subscribed-notifications:subscription-started": {
1315                            "id": 2,
1316                            "ietf-yang-push:datastore": "ietf-datastores:operational",
1317                            "ietf-yang-push:datastore-xpath-filter": "/ietf-hardware:hardware",
1318                            "transport": "ietf-udp-notif-transport:udp-notif",
1319                            "encoding": "encode-json",
1320                            "ietf-distributed-notif:message-publisher-id": [
1321                                16843789
1322                            ],
1323                            "ietf-yang-push-revision:module-version": [
1324                                {
1325                                    "name": "ietf-hardware",
1326                                    "revision": ""
1327                                }
1328                            ],
1329                            "ietf-yang-push-revision:yang-library-content-id": "test-content-id-1",
1330                            "ietf-yang-push:periodic": {
1331                                "period": 6000
1332                            }
1333                        }
1334                    }
1335                }
1336            }
1337        );
1338        let bytes = serde_json::to_vec(&payload).unwrap();
1339        let subscription_started_packet = UdpNotifPacket::new(
1340            MediaType::YangDataJson,
1341            10,
1342            1,
1343            HashMap::new(),
1344            Bytes::from(bytes),
1345        );
1346
1347        // Send SubscriptionStarted packet
1348        udp_notif_tx
1349            .send(Arc::new(UdpNotifRequest::new(
1350                SocketAddr::from(([127, 0, 0, 1], 10000)),
1351                None,
1352                peer,
1353                subscription_started_packet,
1354            )))
1355            .await
1356            .unwrap();
1357
1358        // Allow actor to process
1359        tokio::time::sleep(Duration::from_millis(50)).await;
1360
1361        // Verify packet is not validated
1362        let (content_id, sub_info, _validated) =
1363            tokio::time::timeout(Duration::from_secs(1), validated_rx.recv())
1364                .await
1365                .expect("timeout waiting for response")
1366                .unwrap();
1367        assert!(content_id.is_none());
1368        assert!(!sub_info.is_empty());
1369
1370        // check fetcher was called
1371        {
1372            let hits_counts = fetcher_count
1373                .lock()
1374                .expect("Failed to lock fetcher counts")
1375                .clone();
1376            assert_eq!(hits_counts.len(), 1);
1377        }
1378
1379        // Shutdown actor
1380        handle.shutdown().await.unwrap();
1381        caching_handle.shutdown().await.unwrap();
1382        caching_join_handle.await.unwrap().unwrap();
1383    }
1384}