reachability_core/telemetry/processors/
link_properties_state.rs

1// Copyright 2025 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// TODO(https://fxbug.dev/432298933): Localize the unused tag once reachability
6// starts reporting LinkProperties and LinkState time series data.
7#![allow(unused)]
8
9use fidl_fuchsia_net_interfaces_ext::PortClass;
10use fuchsia_inspect::Node as InspectNode;
11use fuchsia_inspect_contrib::id_enum::IdEnum;
12use fuchsia_sync::Mutex;
13use std::collections::HashMap;
14use std::sync::Arc;
15use windowed_stats::experimental::inspect::{InspectSender, InspectedTimeMatrix};
16use windowed_stats::experimental::series::interpolation::LastSample;
17use windowed_stats::experimental::series::metadata::{BitSetMap, BitSetNode};
18use windowed_stats::experimental::series::statistic::Union;
19use windowed_stats::experimental::series::{SamplingProfile, TimeMatrix};
20
21use crate::{IpVersions, LinkState};
22
23// TODO(https://fxbug.dev/432299715): Share this definition with netcfg.
24//
25// The classification of the interface. This is not necessarily the same
26// as the PortClass of the interface.
27#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)]
28pub enum InterfaceType {
29    Ethernet,
30    WlanClient,
31    WlanAp,
32    Blackhole,
33    Bluetooth,
34    Virtual,
35}
36
37impl std::fmt::Display for InterfaceType {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        let name = format!("{:?}", self);
40        write!(f, "{}", name.to_lowercase())
41    }
42}
43
44// TODO(https://fxbug.dev/432301507): Read this from shared configuration.
45// TODO(https://fxbug.dev/432298588): Add Id and Name as alternate groupings.
46//
47// The specifier for how the time series are initialized. For example, if Type
48// is specified with Ethernet and WlanClient, then there will be a separate
49// time series for Ethernet and WlanClient updates, further broken down by
50// v4 and v6 protocols.
51pub enum InterfaceTimeSeriesGrouping {
52    Type(Vec<InterfaceType>),
53}
54
55// TODO(https://fxbug.dev/432298588): Add Id and Name as alternate groupings.
56//
57// The identifier for the interface. Used to determine which time series should
58// have updates applied.
59#[derive(Clone, Debug, Eq, Hash, PartialEq)]
60pub enum InterfaceIdentifier {
61    // An interface is expected to only have a single type.
62    Type(InterfaceType),
63}
64
65impl std::fmt::Display for InterfaceIdentifier {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        let name = match self {
68            Self::Type(ty) => format!("TYPE_{ty}"),
69        };
70        write!(f, "{}", name)
71    }
72}
73
74pub fn identifiers_from_port_class(port_class: PortClass) -> Vec<InterfaceIdentifier> {
75    match port_class {
76        PortClass::Ethernet => vec![InterfaceType::Ethernet],
77        PortClass::WlanClient => vec![InterfaceType::WlanClient],
78        PortClass::WlanAp => vec![InterfaceType::WlanAp],
79        PortClass::Blackhole => vec![InterfaceType::Blackhole],
80        PortClass::Loopback => vec![InterfaceType::Bluetooth, InterfaceType::Virtual],
81        PortClass::Virtual | PortClass::Ppp | PortClass::Bridge | PortClass::Lowpan => {
82            vec![InterfaceType::Virtual]
83        }
84    }
85    .into_iter()
86    .map(|port_class| InterfaceIdentifier::Type(port_class))
87    .collect()
88}
89
90#[derive(Debug)]
91enum TimeSeriesType {
92    // LinkProperties time series report `LinkProperties` structs, with each
93    // boolean field mapped to a bit in a bitset.
94    LinkProperties,
95    // LinkState time series report `LinkState` enum variants, with each
96    // variant mapped to a bit in a bitset.
97    LinkState,
98}
99
100impl std::fmt::Display for TimeSeriesType {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        let name = match *self {
103            Self::LinkProperties => "link_properties",
104            Self::LinkState => "link_state",
105        };
106        write!(f, "{}", name)
107    }
108}
109
110// A representation of an interface's provisioning status.
111#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
112pub struct LinkProperties {
113    // For IPv4, indicates the acquisition of an IPv4 address. For IPv6,
114    // indicates the acquisition of a non-link local IPv6 address.
115    pub has_address: bool,
116    pub has_default_route: bool,
117    // TODO(https://fxbug.dev/42175016): Perform DNS lookups across the
118    // specified network.
119    //
120    // This is currently a system-wide check. For example, if one interface
121    // can perform DNS resolution, then all of them will report `has_dns` true.
122    pub has_dns: bool,
123    // TODO(https://fxbug.dev/432303907): Separate the HTTP check from DNS.
124    // HTTP can succeed without requiring DNS to be enabled and functional.
125    //
126    // Whether a device can successfully use the HTTP protocol.
127    pub has_http_reachability: bool,
128}
129
130impl LinkProperties {
131    // Each field in the LinkProperties struct represents a single bit. The
132    // first field, `has_address` is the least significant bit. This function
133    // must maintain ordered alignment with `link_properties_metadata`
134    // in `InspectMetadataNode`.
135    fn to_bitset(&self) -> u64 {
136        let mut result = 0u64;
137        if self.has_address {
138            result |= 1;
139        }
140
141        if self.has_default_route {
142            result |= 1 << 1;
143        }
144
145        if self.has_dns {
146            result |= 1 << 2;
147        }
148
149        if self.has_http_reachability {
150            result |= 1 << 3;
151        }
152        result
153    }
154}
155
156#[cfg(test)]
157impl From<u64> for LinkProperties {
158    fn from(data: u64) -> Self {
159        Self {
160            has_address: (data & 1) == 1,
161            has_default_route: (data & 1 << 1) >= 1,
162            has_dns: (data & 1 << 2) >= 1,
163            has_http_reachability: (data & 1 << 3) >= 1,
164        }
165    }
166}
167
168impl IdEnum for LinkState {
169    type Id = u8;
170    // This function must maintain ordered alignment with `link_state_metadata`
171    // in `InspectMetadataNode`.
172    fn to_id(&self) -> Self::Id {
173        match self {
174            LinkState::None => 0,
175            LinkState::Removed => 1,
176            LinkState::Down => 2,
177            LinkState::Up => 3,
178            LinkState::Local => 4,
179            LinkState::Gateway => 5,
180            LinkState::Internet => 6,
181        }
182    }
183}
184
185#[cfg(test)]
186impl From<u64> for LinkState {
187    fn from(data: u64) -> Self {
188        match data {
189            0 => Self::None,
190            1 => Self::Removed,
191            2 => Self::Down,
192            3 => Self::Up,
193            4 => Self::Local,
194            5 => Self::Gateway,
195            6 => Self::Internet,
196            unknown => {
197                log::info!("invalid ordinal provided: {data:?}");
198                Self::None
199            }
200        }
201    }
202}
203
204// Create a time series for IPv4 and IPv6 stored in `IpVersions`.
205fn ip_versions_time_series<S: InspectSender>(
206    client: &S,
207    inspect_metadata_path: &str,
208    series_type: TimeSeriesType,
209    identifier: InterfaceIdentifier,
210) -> IpVersions<InspectedTimeMatrix<u64>> {
211    // Time series of the same type report the same metadata and can use the
212    // same metadata node.
213    let metadata_node = match series_type {
214        TimeSeriesType::LinkProperties => InspectMetadataNode::LINK_PROPERTIES,
215        TimeSeriesType::LinkState => InspectMetadataNode::LINK_STATE,
216    };
217    let bitset_node = BitSetNode::from_path(format!("{}/{}", inspect_metadata_path, metadata_node));
218    // A separate time matrix is created for IPv4 and IPv6.
219    IpVersions {
220        ipv4: single_time_matrix(
221            client,
222            format!("{}_v4_{}", series_type, identifier),
223            bitset_node.clone(),
224        ),
225        ipv6: single_time_matrix(client, format!("{}_v6_{}", series_type, identifier), bitset_node),
226    }
227}
228
229// Create a single u64 time matrix with a highly granular sampling profile.
230fn single_time_matrix<S: InspectSender>(
231    client: &S,
232    time_series_name: String,
233    bitset_node: BitSetNode,
234) -> InspectedTimeMatrix<u64> {
235    client.inspect_time_matrix_with_metadata(
236        time_series_name,
237        TimeMatrix::<Union<u64>, LastSample>::new(
238            SamplingProfile::highly_granular(),
239            LastSample::or(0),
240        ),
241        bitset_node,
242    )
243}
244
245impl IpVersions<InspectedTimeMatrix<u64>> {
246    // Helper functions to make logging based on protocol cleaner.
247    fn log_v4(&self, data: u64) {
248        self.ipv4.fold_or_log_error(data);
249    }
250
251    fn log_v6(&self, data: u64) {
252        self.ipv6.fold_or_log_error(data);
253    }
254}
255
256// State for tracking link properties and link state across an interface,
257// backed by time series.
258struct PerInterfaceTimeSeries {
259    // The most recent link properties for the interface.
260    link_properties: Arc<Mutex<IpVersions<LinkProperties>>>,
261    // Time matrix for tracking changes in `link_properties`.
262    link_properties_time_matrix: IpVersions<InspectedTimeMatrix<u64>>,
263    // The most recent link state for the interface.
264    link_state: Arc<Mutex<IpVersions<LinkState>>>,
265    // Time matrix for tracking changes in `link_state`.
266    link_state_time_matrix: IpVersions<InspectedTimeMatrix<u64>>,
267}
268
269impl PerInterfaceTimeSeries {
270    pub fn new<S: InspectSender>(
271        client: &S,
272        inspect_metadata_path: &str,
273        identifier: InterfaceIdentifier,
274    ) -> Self {
275        Self {
276            link_properties: Arc::new(Mutex::new(IpVersions::default())),
277            link_properties_time_matrix: ip_versions_time_series(
278                client,
279                inspect_metadata_path,
280                TimeSeriesType::LinkProperties,
281                identifier.clone(),
282            ),
283            link_state: Arc::new(Mutex::new(IpVersions::default())),
284            link_state_time_matrix: ip_versions_time_series(
285                client,
286                inspect_metadata_path,
287                TimeSeriesType::LinkState,
288                identifier,
289            ),
290        }
291    }
292
293    fn log_link_properties_v4(&self, link_properties: LinkProperties) {
294        self.link_properties_time_matrix.log_v4(link_properties.to_bitset());
295    }
296
297    fn log_link_properties_v6(&self, link_properties: LinkProperties) {
298        self.link_properties_time_matrix.log_v6(link_properties.to_bitset());
299    }
300
301    fn maybe_log_link_properties(&self, new: &IpVersions<LinkProperties>) {
302        let mut curr = self.link_properties.lock();
303
304        // TODO(https://fxbug.dev/432304519): `InterfaceType` groupings need to
305        // be specially handled. If multiple interfaces of the same type
306        // co-exist, then the one that is last updated will take precedence.
307        // It is desired that the 'highest' rating link properties of the
308        // provided type will be reported.
309        if new.ipv4 != curr.ipv4 {
310            curr.ipv4 = new.ipv4;
311            self.log_link_properties_v4(curr.ipv4);
312        }
313
314        if new.ipv6 != curr.ipv6 {
315            curr.ipv6 = new.ipv6;
316            self.log_link_properties_v6(curr.ipv6);
317        }
318    }
319
320    fn log_link_state_v4(&self, link_state: LinkState) {
321        self.link_state_time_matrix.log_v4(1 << (link_state.to_id() as u64));
322    }
323
324    fn log_link_state_v6(&self, link_state: LinkState) {
325        self.link_state_time_matrix.log_v6(1 << (link_state.to_id() as u64));
326    }
327
328    fn maybe_log_link_state(&self, new: &IpVersions<LinkState>) {
329        let mut curr = self.link_state.lock();
330
331        // TODO(https://fxbug.dev/432304519): See `maybe_log_link_properties`.
332        if new.ipv4 != curr.ipv4 {
333            curr.ipv4 = new.ipv4;
334            self.log_link_state_v4(curr.ipv4);
335        }
336
337        if new.ipv6 != curr.ipv6 {
338            curr.ipv6 = new.ipv6;
339            self.log_link_state_v6(curr.ipv6);
340        }
341    }
342}
343
344// The wrapper for the time series reporting for LinkProperties and LinkState.
345pub struct LinkPropertiesStateLogger {
346    // Tracks the provided `InterfaceIdentifier`s against the time series for
347    // that identifier. Entries are only created during initialization.
348    time_series_stats: HashMap<InterfaceIdentifier, PerInterfaceTimeSeries>,
349    inspect_metadata_node: InspectMetadataNode,
350}
351
352impl LinkPropertiesStateLogger {
353    pub fn new<S: InspectSender>(
354        inspect_metadata_node: &InspectNode,
355        inspect_metadata_path: &str,
356        interface_grouping: InterfaceTimeSeriesGrouping,
357        time_matrix_client: &S,
358    ) -> Self {
359        Self {
360            // Create a time series per interface type provided.
361            time_series_stats: match interface_grouping {
362                InterfaceTimeSeriesGrouping::Type(tys) => tys.into_iter().map(|ty| {
363                    let identifier = InterfaceIdentifier::Type(ty);
364                    (
365                        identifier.clone(),
366                        PerInterfaceTimeSeries::new(
367                            time_matrix_client,
368                            inspect_metadata_path,
369                            identifier,
370                        ),
371                    )
372                }),
373            }
374            .collect(),
375            inspect_metadata_node: InspectMetadataNode::new(inspect_metadata_node),
376        }
377    }
378
379    // Update an interface's `LinkProperties`. `interface_identifiers`
380    // represent the various ways that an interface can be identified. When an
381    // identifier matches an attribute that is being tracked in
382    // `time_series_stats`, attempt to log that `LinkProperties` update.
383    pub fn update_link_properties(
384        &self,
385        interface_identifiers: Vec<InterfaceIdentifier>,
386        link_properties: &IpVersions<LinkProperties>,
387    ) {
388        interface_identifiers.iter().for_each(|identifier| {
389            if let Some(time_series) = self.time_series_stats.get(identifier) {
390                time_series.maybe_log_link_properties(&link_properties);
391            }
392        });
393    }
394
395    // Update an interface's `LinkState`. `interface_identifiers` represent the
396    // various ways that an interface can be identified. When an identifier
397    // matches an attribute that is being tracked in `time_series_stats`,
398    // attempt to log that `LinkState` update.
399    pub fn update_link_state(
400        &self,
401        interface_identifiers: Vec<InterfaceIdentifier>,
402        link_state: &IpVersions<LinkState>,
403    ) {
404        interface_identifiers.iter().for_each(|identifier| {
405            if let Some(time_series) = self.time_series_stats.get(identifier) {
406                time_series.maybe_log_link_state(&link_state);
407            }
408        });
409    }
410}
411
412// Holds the inspect node children for the static metadata that correlates to
413// bits in each of the corresponding structs / enums.
414struct InspectMetadataNode {
415    link_properties: InspectNode,
416    link_state: InspectNode,
417}
418
419impl InspectMetadataNode {
420    const LINK_PROPERTIES: &'static str = "link_properties";
421    const LINK_STATE: &'static str = "link_state";
422
423    fn new(inspect_node: &InspectNode) -> Self {
424        let link_properties = inspect_node.create_child(Self::LINK_PROPERTIES);
425        let link_state = inspect_node.create_child(Self::LINK_STATE);
426
427        let link_properties_metadata = BitSetMap::from_ordered([
428            "has_address",
429            "has_default_route",
430            "has_dns",
431            "has_http_reachability",
432        ]);
433        let link_state_metadata = BitSetMap::from_ordered([
434            "None", "Removed", "Down", "Up", "Local", "Gateway", "Internet",
435        ]);
436
437        link_properties_metadata.record(&link_properties);
438        link_state_metadata.record(&link_state);
439
440        Self { link_properties, link_state }
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use diagnostics_assertions::{AnyBytesProperty, assert_data_tree};
448
449    use crate::telemetry::testing::setup_test;
450    use windowed_stats::experimental::clock::Timed;
451    use windowed_stats::experimental::inspect::TimeMatrixClient;
452    use windowed_stats::experimental::testing::TimeMatrixCall;
453
454    #[fuchsia::test]
455    fn test_log_time_series_metadata_to_inspect() {
456        let mut harness = setup_test();
457
458        let client =
459            TimeMatrixClient::new(harness.inspect_node.create_child("link_properties_state"));
460        let _link_properties_state = LinkPropertiesStateLogger::new(
461            &harness.inspect_metadata_node,
462            &harness.inspect_metadata_path,
463            InterfaceTimeSeriesGrouping::Type(vec![InterfaceType::Ethernet]),
464            &client,
465        );
466
467        let tree = harness.get_inspect_data_tree();
468        assert_data_tree!(
469            @executor harness.exec,
470            tree,
471            root: contains {
472                test_stats: contains {
473                    metadata: {
474                        link_properties: {
475                            index: {
476                                "0": "has_address",
477                                "1": "has_default_route",
478                                "2": "has_dns",
479                                "3": "has_http_reachability",
480                            },
481                        },
482                        link_state: {
483                            index: {
484                                "0": "None",
485                                "1": "Removed",
486                                "2": "Down",
487                                "3": "Up",
488                                "4": "Local",
489                                "5": "Gateway",
490                                "6": "Internet",
491                            },
492                        },
493                    },
494                    link_properties_state: contains {
495                        link_properties_v4_TYPE_ethernet: {
496                            "type": "bitset",
497                            "data": AnyBytesProperty,
498                            metadata: {
499                                index_node_path: "root/test_stats/metadata/link_properties",
500                            }
501                        },
502                        link_properties_v6_TYPE_ethernet: {
503                            "type": "bitset",
504                            "data": AnyBytesProperty,
505                            metadata: {
506                                index_node_path: "root/test_stats/metadata/link_properties",
507                            }
508                        },
509                        link_state_v4_TYPE_ethernet: {
510                            "type": "bitset",
511                            "data": AnyBytesProperty,
512                            metadata: {
513                                index_node_path: "root/test_stats/metadata/link_state",
514                            }
515                        },
516                        link_state_v6_TYPE_ethernet: {
517                            "type": "bitset",
518                            "data": AnyBytesProperty,
519                            metadata: {
520                                index_node_path: "root/test_stats/metadata/link_state",
521                            }
522                        }
523                    }
524                }
525            }
526        )
527    }
528
529    #[fuchsia::test]
530    fn test_log_link_properties() {
531        let harness = setup_test();
532
533        let link_properties_state = LinkPropertiesStateLogger::new(
534            &harness.inspect_metadata_node,
535            &harness.inspect_metadata_path,
536            InterfaceTimeSeriesGrouping::Type(vec![InterfaceType::Ethernet]),
537            &harness.mock_time_matrix_client,
538        );
539
540        // Update the link properties with an interface type not present in the types
541        // provided to `LinkPropertiesStateLogger`.
542        link_properties_state.update_link_properties(
543            vec![InterfaceIdentifier::Type(InterfaceType::WlanClient)],
544            &IpVersions {
545                ipv4: LinkProperties { has_address: true, ..Default::default() },
546                ipv6: LinkProperties::default(),
547            },
548        );
549
550        // There should be no calls to the `TYPE_ethernet` time series since the
551        // update above was for `WlanClient`. There should be no calls to the
552        // `TYPE_wlanclient` field either since they were not initialized.
553        let mut time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
554        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_ethernet")[..], &[]);
555        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_ethernet")[..], &[]);
556        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_wlanclient")[..], &[]);
557        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_wlanclient")[..], &[]);
558
559        // Update the link properties with a present interface type.
560        link_properties_state.update_link_properties(
561            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
562            &IpVersions {
563                ipv4: LinkProperties { has_address: true, ..Default::default() },
564                ipv6: LinkProperties {
565                    has_address: true,
566                    has_default_route: true,
567                    ..Default::default()
568                },
569            },
570        );
571
572        time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
573        // The first bit is set for the v4 call, since `has_address` is true.
574        assert_eq!(
575            &time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_ethernet")[..],
576            &[TimeMatrixCall::Fold(Timed::now(1 << 0)),]
577        );
578        // The first and second bit are set for the v6 call, since `has_address`
579        // and `has_default_route` are true.
580        assert_eq!(
581            &time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_ethernet")[..],
582            &[TimeMatrixCall::Fold(Timed::now((1 << 0) | (1 << 1))),]
583        );
584
585        // Ensure that updating the same identifier with the same properties
586        // for v4 results in no calls to the v4 time matrix.
587        link_properties_state.update_link_properties(
588            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
589            &IpVersions {
590                ipv4: LinkProperties { has_address: true, ..Default::default() },
591                ipv6: LinkProperties {
592                    has_address: true,
593                    has_default_route: true,
594                    has_dns: true,
595                    ..Default::default()
596                },
597            },
598        );
599        time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
600        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_ethernet")[..], &[]);
601        assert_eq!(
602            &time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_ethernet")[..],
603            &[TimeMatrixCall::Fold(Timed::now((1 << 0) | (1 << 1) | (1 << 2))),]
604        );
605    }
606
607    #[fuchsia::test]
608    fn test_log_link_state() {
609        let harness = setup_test();
610
611        let link_properties_state = LinkPropertiesStateLogger::new(
612            &harness.inspect_metadata_node,
613            &harness.inspect_metadata_path,
614            InterfaceTimeSeriesGrouping::Type(vec![InterfaceType::Ethernet]),
615            &harness.mock_time_matrix_client,
616        );
617
618        // Update the link state with an interface type not present in the types
619        // provided to `LinkPropertiesStateLogger`.
620        link_properties_state.update_link_state(
621            vec![InterfaceIdentifier::Type(InterfaceType::WlanClient)],
622            &IpVersions { ipv4: Default::default(), ipv6: LinkState::Gateway },
623        );
624
625        // There should be no calls to the `TYPE_ethernet` time series since the
626        // update above was for `WlanClient`. There should be no calls to the
627        // `TYPE_wlanclient` field either since they were not initialized.
628        let mut time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
629        assert_eq!(&time_matrix_calls.drain::<u64>("link_state_v4_TYPE_ethernet")[..], &[]);
630        assert_eq!(&time_matrix_calls.drain::<u64>("link_state_v6_TYPE_ethernet")[..], &[]);
631        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_wlanclient")[..], &[]);
632        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_wlanclient")[..], &[]);
633
634        // Update the link state with a present interface type.
635        link_properties_state.update_link_state(
636            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
637            &IpVersions { ipv4: LinkState::Internet, ipv6: LinkState::Local },
638        );
639
640        time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
641        assert_eq!(
642            &time_matrix_calls.drain::<u64>("link_state_v4_TYPE_ethernet")[..],
643            &[TimeMatrixCall::Fold(Timed::now(1 << LinkState::Internet.to_id())),]
644        );
645        assert_eq!(
646            &time_matrix_calls.drain::<u64>("link_state_v6_TYPE_ethernet")[..],
647            &[TimeMatrixCall::Fold(Timed::now(1 << LinkState::Local.to_id())),]
648        );
649
650        // Ensure that updating the same identifier with the same properties
651        // for v4 results in no calls to the v4 time matrix.
652        link_properties_state.update_link_state(
653            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
654            &IpVersions { ipv4: LinkState::Internet, ipv6: LinkState::Gateway },
655        );
656        time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
657        assert_eq!(&time_matrix_calls.drain::<u64>("link_state_v4_TYPE_ethernet")[..], &[]);
658        assert_eq!(
659            &time_matrix_calls.drain::<u64>("link_state_v6_TYPE_ethernet")[..],
660            &[TimeMatrixCall::Fold(Timed::now(1 << LinkState::Gateway.to_id())),]
661        );
662    }
663}