Skip to main content

oxirs_core/views/
incremental.rs

1//! Incremental view maintenance with delta propagation.
2//!
3//! This module provides:
4//! - [`ViewDefinition`]: Declares a named, materialized SPARQL view.
5//! - [`DeltaChange`]: Represents a single triple insert or delete.
6//! - [`ViewRow`]: One result row in a materialized view (variable → value map).
7//! - [`MaterializedView`]: The cached result set of a view together with staleness metadata.
8//! - [`IncrementalViewMaintainer`]: Manages a registry of views and propagates delta changes.
9//! - [`ViewStalenessDetector`]: Utility helpers to test whether views have grown stale over time.
10
11use std::collections::HashMap;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14// ---------------------------------------------------------------------------
15// Helpers
16// ---------------------------------------------------------------------------
17
18fn now_ms() -> i64 {
19    SystemTime::now()
20        .duration_since(UNIX_EPOCH)
21        .map(|d| d.as_millis() as i64)
22        .unwrap_or(0)
23}
24
25// ---------------------------------------------------------------------------
26// ViewDefinition
27// ---------------------------------------------------------------------------
28
29/// Metadata that describes a named, materialized SPARQL view.
30///
31/// The `dependencies` field lists the predicate IRIs that the SPARQL query
32/// accesses.  It is used for selective delta propagation: only views that
33/// mention a changed predicate in their `dependencies` (or have an empty list,
34/// meaning "depends on everything") are invalidated when a triple changes.
35#[derive(Debug, Clone)]
36pub struct ViewDefinition {
37    /// Human-readable name that serves as the unique key in a maintainer.
38    pub name: String,
39    /// The SPARQL SELECT query that defines the view's result set.
40    pub sparql_query: String,
41    /// Whether the view caches its result rows (materialized) or is virtual.
42    pub is_materialized: bool,
43    /// Predicate IRIs this view's query accesses.
44    ///
45    /// An *empty* list means the view depends on **all** predicates (universal
46    /// dependency), so it is invalidated by any triple change.
47    pub dependencies: Vec<String>,
48}
49
50impl ViewDefinition {
51    /// Create a new view definition.
52    pub fn new(
53        name: impl Into<String>,
54        sparql_query: impl Into<String>,
55        is_materialized: bool,
56        dependencies: Vec<String>,
57    ) -> Self {
58        Self {
59            name: name.into(),
60            sparql_query: sparql_query.into(),
61            is_materialized,
62            dependencies,
63        }
64    }
65
66    /// Return `true` if the given `predicate` IRI is listed in the
67    /// `dependencies`, or if `dependencies` is empty (universal dependency).
68    pub fn depends_on(&self, predicate: &str) -> bool {
69        self.dependencies.is_empty() || self.dependencies.iter().any(|p| p.as_str() == predicate)
70    }
71}
72
73// ---------------------------------------------------------------------------
74// DeltaChange
75// ---------------------------------------------------------------------------
76
77/// A single modification to the underlying triple store.
78///
79/// Both variants carry the `(subject, predicate, object)` triple as owned
80/// `String`s to keep the API self-contained.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum DeltaChange {
83    /// A triple was added to the store.
84    Insert {
85        subject: String,
86        predicate: String,
87        object: String,
88    },
89    /// A triple was removed from the store.
90    Delete {
91        subject: String,
92        predicate: String,
93        object: String,
94    },
95}
96
97impl DeltaChange {
98    /// Return the predicate IRI of this change.
99    pub fn predicate(&self) -> &str {
100        match self {
101            DeltaChange::Insert { predicate, .. } | DeltaChange::Delete { predicate, .. } => {
102                predicate.as_str()
103            }
104        }
105    }
106
107    /// Return the subject IRI of this change.
108    pub fn subject(&self) -> &str {
109        match self {
110            DeltaChange::Insert { subject, .. } | DeltaChange::Delete { subject, .. } => {
111                subject.as_str()
112            }
113        }
114    }
115
116    /// Return the object value of this change.
117    pub fn object(&self) -> &str {
118        match self {
119            DeltaChange::Insert { object, .. } | DeltaChange::Delete { object, .. } => {
120                object.as_str()
121            }
122        }
123    }
124
125    /// Return `true` if this is an insertion.
126    pub fn is_insert(&self) -> bool {
127        matches!(self, DeltaChange::Insert { .. })
128    }
129}
130
131// ---------------------------------------------------------------------------
132// ViewRow
133// ---------------------------------------------------------------------------
134
135/// One result row in a materialized view: a mapping from SPARQL variable
136/// names to their bound string values.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ViewRow(pub HashMap<String, String>);
139
140impl ViewRow {
141    /// Create a new row from a plain `HashMap`.
142    pub fn new(map: HashMap<String, String>) -> Self {
143        Self(map)
144    }
145
146    /// Convenience constructor for tests and small code paths.
147    pub fn from_pairs(pairs: &[(&str, &str)]) -> Self {
148        Self(
149            pairs
150                .iter()
151                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
152                .collect(),
153        )
154    }
155
156    /// Return the value bound to `variable`, if any.
157    pub fn get(&self, variable: &str) -> Option<&str> {
158        self.0.get(variable).map(|s| s.as_str())
159    }
160}
161
162// ---------------------------------------------------------------------------
163// MaterializedView
164// ---------------------------------------------------------------------------
165
166/// The cached result set of a named SPARQL view.
167///
168/// `last_updated_ms` is a Unix timestamp in milliseconds; `version` is
169/// incremented every time the view is refreshed.
170pub struct MaterializedView {
171    /// The definition that drives this view.
172    pub definition: ViewDefinition,
173    /// Cached result rows.
174    pub rows: Vec<ViewRow>,
175    /// Unix timestamp (ms) when the view was last refreshed.
176    pub last_updated_ms: i64,
177    /// Monotonically increasing refresh counter.
178    pub version: u64,
179    /// Whether the cached rows are out-of-date.
180    pub is_stale: bool,
181}
182
183impl MaterializedView {
184    /// Create a new `MaterializedView` with the given initial rows.
185    ///
186    /// `last_updated_ms` is set to the current wall-clock time and `version`
187    /// starts at 0.
188    pub fn new(definition: ViewDefinition, initial_rows: Vec<ViewRow>) -> Self {
189        Self {
190            definition,
191            rows: initial_rows,
192            last_updated_ms: now_ms(),
193            version: 0,
194            is_stale: false,
195        }
196    }
197
198    /// Replace the current rows with `new_rows`, clear the stale flag, and
199    /// bump `version`.
200    pub fn refresh(&mut self, new_rows: Vec<ViewRow>) {
201        self.rows = new_rows;
202        self.last_updated_ms = now_ms();
203        self.version += 1;
204        self.is_stale = false;
205    }
206
207    /// Mark the view as stale (i.e. its cached rows need to be recomputed).
208    pub fn invalidate(&mut self) {
209        self.is_stale = true;
210    }
211}
212
213// ---------------------------------------------------------------------------
214// IncrementalViewMaintainer
215// ---------------------------------------------------------------------------
216
217/// Manages a collection of [`MaterializedView`]s and propagates delta changes.
218///
219/// # Workflow
220///
221/// 1. Call [`register_view`] to create a new view from a [`ViewDefinition`] and
222///    an initial result set.
223/// 2. Call [`apply_delta`] (or [`queue_change`] + [`flush_changes`]) whenever
224///    the underlying triple store changes.
225/// 3. Invalidated views can be re-evaluated by the caller and refreshed with
226///    new rows via internal access to the views map (or by replacing the whole
227///    view).
228///
229/// [`register_view`]: IncrementalViewMaintainer::register_view
230/// [`apply_delta`]: IncrementalViewMaintainer::apply_delta
231/// [`queue_change`]: IncrementalViewMaintainer::queue_change
232/// [`flush_changes`]: IncrementalViewMaintainer::flush_changes
233pub struct IncrementalViewMaintainer {
234    views: HashMap<String, MaterializedView>,
235    change_queue: Vec<DeltaChange>,
236}
237
238impl IncrementalViewMaintainer {
239    /// Create an empty maintainer.
240    pub fn new() -> Self {
241        Self {
242            views: HashMap::new(),
243            change_queue: Vec::new(),
244        }
245    }
246
247    /// Register a named view.
248    ///
249    /// If a view with the same `definition.name` already exists it is
250    /// replaced.
251    pub fn register_view(&mut self, def: ViewDefinition, initial_rows: Vec<ViewRow>) {
252        let name = def.name.clone();
253        let view = MaterializedView::new(def, initial_rows);
254        self.views.insert(name, view);
255    }
256
257    /// Apply a single [`DeltaChange`] immediately.
258    ///
259    /// Returns the names of views that were invalidated (i.e. whose
260    /// `dependencies` include the changed predicate or are empty).
261    /// Views that are already stale are *not* returned again (no-duplicate
262    /// semantics).
263    pub fn apply_delta(&mut self, change: DeltaChange) -> Vec<String> {
264        let predicate = change.predicate().to_string();
265        let mut invalidated = Vec::new();
266
267        for (name, view) in self.views.iter_mut() {
268            if !view.is_stale && view.definition.depends_on(&predicate) {
269                view.is_stale = true;
270                invalidated.push(name.clone());
271            }
272        }
273
274        invalidated
275    }
276
277    /// Mark a view as stale by name.
278    ///
279    /// Does nothing if the view does not exist.
280    pub fn invalidate_view(&mut self, name: &str) {
281        if let Some(view) = self.views.get_mut(name) {
282            view.invalidate();
283        }
284    }
285
286    /// Return an immutable reference to a view.
287    pub fn get_view(&self, name: &str) -> Option<&MaterializedView> {
288        self.views.get(name)
289    }
290
291    /// Return the names of all registered views, in unspecified order.
292    pub fn list_views(&self) -> Vec<&str> {
293        self.views.keys().map(|s| s.as_str()).collect()
294    }
295
296    /// Return the names of all views that depend on `predicate`.
297    ///
298    /// This includes views with an empty `dependencies` list (universal
299    /// dependency).
300    pub fn affected_views(&self, predicate: &str) -> Vec<&str> {
301        self.views
302            .iter()
303            .filter(|(_, v)| v.definition.depends_on(predicate))
304            .map(|(name, _)| name.as_str())
305            .collect()
306    }
307
308    /// Push a change onto the internal queue for later batch processing.
309    pub fn queue_change(&mut self, change: DeltaChange) {
310        self.change_queue.push(change);
311    }
312
313    /// Apply all queued changes at once and clear the queue.
314    ///
315    /// Returns a map of `view_name → rows_changed`.  Because this
316    /// implementation uses a mark-stale strategy, invalidated views are
317    /// reported with `0` changed rows.  Views that were not affected are not
318    /// included in the returned map.
319    pub fn flush_changes(&mut self) -> HashMap<String, usize> {
320        let changes: Vec<DeltaChange> = self.change_queue.drain(..).collect();
321        let mut result: HashMap<String, usize> = HashMap::new();
322
323        for change in changes {
324            let invalidated = self.apply_delta(change);
325            for name in invalidated {
326                // Mark-stale strategy: report 0 rows changed for each newly
327                // invalidated view.  Views already stale are not re-added.
328                result.entry(name).or_insert(0);
329            }
330        }
331
332        result
333    }
334
335    /// Return the number of registered views.
336    pub fn view_count(&self) -> usize {
337        self.views.len()
338    }
339
340    /// Return the total number of cached rows across all non-stale views.
341    pub fn total_rows(&self) -> usize {
342        self.views
343            .values()
344            .filter(|v| !v.is_stale)
345            .map(|v| v.rows.len())
346            .sum()
347    }
348}
349
350impl Default for IncrementalViewMaintainer {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356// ---------------------------------------------------------------------------
357// ViewStalenessDetector
358// ---------------------------------------------------------------------------
359
360/// Utility helpers for determining whether materialized views have grown stale
361/// based on wall-clock age.
362pub struct ViewStalenessDetector;
363
364impl ViewStalenessDetector {
365    /// Return `true` if `view` was last updated more than `max_age_ms`
366    /// milliseconds ago, or if it is already marked stale.
367    pub fn is_stale(view: &MaterializedView, max_age_ms: i64) -> bool {
368        if view.is_stale {
369            return true;
370        }
371        let age = now_ms() - view.last_updated_ms;
372        age > max_age_ms
373    }
374
375    /// Filter a slice of view references and return those that are stale
376    /// according to [`is_stale`].
377    ///
378    /// [`is_stale`]: ViewStalenessDetector::is_stale
379    pub fn stale_views<'a>(
380        views: &[&'a MaterializedView],
381        max_age_ms: i64,
382    ) -> Vec<&'a MaterializedView> {
383        views
384            .iter()
385            .copied()
386            .filter(|v| Self::is_stale(v, max_age_ms))
387            .collect()
388    }
389}
390
391// ---------------------------------------------------------------------------
392// Tests
393// ---------------------------------------------------------------------------
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use std::thread;
399    use std::time::Duration;
400
401    // ---- helpers ----
402
403    fn make_def(name: &str, deps: &[&str], materialized: bool) -> ViewDefinition {
404        ViewDefinition::new(
405            name,
406            format!("SELECT * WHERE {{ ?s <http://p/{}> ?o }}", name),
407            materialized,
408            deps.iter().map(|s| s.to_string()).collect(),
409        )
410    }
411
412    fn rows(n: usize) -> Vec<ViewRow> {
413        (0..n)
414            .map(|i| ViewRow::from_pairs(&[("s", &format!("s{}", i)), ("o", &format!("o{}", i))]))
415            .collect()
416    }
417
418    // ---- ViewDefinition ----
419
420    #[test]
421    fn test_view_definition_depends_on_listed_predicate() {
422        let def = make_def("v", &["http://p/age"], true);
423        assert!(def.depends_on("http://p/age"));
424        assert!(!def.depends_on("http://p/name"));
425    }
426
427    #[test]
428    fn test_view_definition_empty_deps_matches_all() {
429        let def = make_def("v", &[], true);
430        assert!(def.depends_on("http://anything"));
431    }
432
433    #[test]
434    fn test_view_definition_multiple_deps() {
435        let def = make_def("v", &["http://p/age", "http://p/name"], false);
436        assert!(def.depends_on("http://p/age"));
437        assert!(def.depends_on("http://p/name"));
438        assert!(!def.depends_on("http://p/color"));
439    }
440
441    // ---- DeltaChange ----
442
443    #[test]
444    fn test_delta_change_insert_accessors() {
445        let d = DeltaChange::Insert {
446            subject: "s".into(),
447            predicate: "p".into(),
448            object: "o".into(),
449        };
450        assert_eq!(d.subject(), "s");
451        assert_eq!(d.predicate(), "p");
452        assert_eq!(d.object(), "o");
453        assert!(d.is_insert());
454    }
455
456    #[test]
457    fn test_delta_change_delete_accessors() {
458        let d = DeltaChange::Delete {
459            subject: "s".into(),
460            predicate: "p".into(),
461            object: "o".into(),
462        };
463        assert!(!d.is_insert());
464    }
465
466    #[test]
467    fn test_delta_change_equality() {
468        let a = DeltaChange::Insert {
469            subject: "s".into(),
470            predicate: "p".into(),
471            object: "o".into(),
472        };
473        let b = a.clone();
474        assert_eq!(a, b);
475    }
476
477    // ---- ViewRow ----
478
479    #[test]
480    fn test_view_row_get() {
481        let row = ViewRow::from_pairs(&[("x", "Alice"), ("y", "30")]);
482        assert_eq!(row.get("x"), Some("Alice"));
483        assert_eq!(row.get("z"), None);
484    }
485
486    // ---- MaterializedView ----
487
488    #[test]
489    fn test_materialized_view_initial_state() {
490        let def = make_def("v", &["http://p/age"], true);
491        let mv = MaterializedView::new(def, rows(3));
492        assert_eq!(mv.rows.len(), 3);
493        assert_eq!(mv.version, 0);
494        assert!(!mv.is_stale);
495    }
496
497    #[test]
498    fn test_materialized_view_refresh_bumps_version() {
499        let def = make_def("v", &["http://p/age"], true);
500        let mut mv = MaterializedView::new(def, rows(2));
501        mv.refresh(rows(5));
502        assert_eq!(mv.rows.len(), 5);
503        assert_eq!(mv.version, 1);
504        assert!(!mv.is_stale);
505    }
506
507    #[test]
508    fn test_materialized_view_invalidate_sets_stale() {
509        let def = make_def("v", &["http://p/age"], true);
510        let mut mv = MaterializedView::new(def, rows(2));
511        mv.invalidate();
512        assert!(mv.is_stale);
513    }
514
515    // ---- IncrementalViewMaintainer ----
516
517    #[test]
518    fn test_maintainer_register_and_count() {
519        let mut m = IncrementalViewMaintainer::new();
520        m.register_view(make_def("v1", &["http://p/age"], true), rows(1));
521        m.register_view(make_def("v2", &["http://p/name"], true), rows(2));
522        assert_eq!(m.view_count(), 2);
523    }
524
525    #[test]
526    fn test_maintainer_apply_delta_invalidates_affected() {
527        let mut m = IncrementalViewMaintainer::new();
528        m.register_view(make_def("v_age", &["http://p/age"], true), rows(2));
529        m.register_view(make_def("v_name", &["http://p/name"], true), rows(3));
530
531        let changed = m.apply_delta(DeltaChange::Insert {
532            subject: "s".into(),
533            predicate: "http://p/age".into(),
534            object: "42".into(),
535        });
536
537        assert_eq!(changed.len(), 1);
538        assert_eq!(changed[0], "v_age");
539        assert!(m.get_view("v_age").map(|v| v.is_stale).unwrap_or(false));
540        assert!(!m.get_view("v_name").map(|v| v.is_stale).unwrap_or(true));
541    }
542
543    #[test]
544    fn test_maintainer_apply_delta_already_stale_not_returned_again() {
545        let mut m = IncrementalViewMaintainer::new();
546        m.register_view(make_def("v", &["http://p/age"], true), rows(1));
547
548        m.apply_delta(DeltaChange::Insert {
549            subject: "s".into(),
550            predicate: "http://p/age".into(),
551            object: "1".into(),
552        });
553        // Second delta on the same predicate — v is already stale.
554        let changed = m.apply_delta(DeltaChange::Insert {
555            subject: "s2".into(),
556            predicate: "http://p/age".into(),
557            object: "2".into(),
558        });
559        assert!(
560            changed.is_empty(),
561            "Already stale, should not be re-reported"
562        );
563    }
564
565    #[test]
566    fn test_maintainer_apply_delta_universal_dependency() {
567        let mut m = IncrementalViewMaintainer::new();
568        m.register_view(make_def("v_all", &[], true), rows(0)); // empty deps = all
569
570        let changed = m.apply_delta(DeltaChange::Delete {
571            subject: "s".into(),
572            predicate: "http://totally/unknown".into(),
573            object: "o".into(),
574        });
575        assert_eq!(changed.len(), 1);
576    }
577
578    #[test]
579    fn test_maintainer_invalidate_view_by_name() {
580        let mut m = IncrementalViewMaintainer::new();
581        m.register_view(make_def("v", &["http://p/x"], true), rows(1));
582        m.invalidate_view("v");
583        assert!(m.get_view("v").map(|v| v.is_stale).unwrap_or(false));
584    }
585
586    #[test]
587    fn test_maintainer_invalidate_nonexistent_view_is_noop() {
588        let mut m = IncrementalViewMaintainer::new();
589        // Should not panic.
590        m.invalidate_view("does_not_exist");
591    }
592
593    #[test]
594    fn test_maintainer_affected_views_returns_matching() {
595        let mut m = IncrementalViewMaintainer::new();
596        m.register_view(make_def("v_age", &["http://p/age"], true), rows(0));
597        m.register_view(make_def("v_name", &["http://p/name"], true), rows(0));
598        m.register_view(make_def("v_all", &[], true), rows(0));
599
600        let mut affected = m.affected_views("http://p/age");
601        affected.sort_unstable();
602        // v_age and v_all should be returned; v_name should not.
603        assert!(affected.contains(&"v_age"));
604        assert!(affected.contains(&"v_all"));
605        assert!(!affected.contains(&"v_name"));
606    }
607
608    #[test]
609    fn test_maintainer_queue_and_flush_changes() {
610        let mut m = IncrementalViewMaintainer::new();
611        m.register_view(make_def("v_age", &["http://p/age"], true), rows(3));
612        m.register_view(make_def("v_name", &["http://p/name"], true), rows(2));
613
614        m.queue_change(DeltaChange::Insert {
615            subject: "s1".into(),
616            predicate: "http://p/age".into(),
617            object: "25".into(),
618        });
619        m.queue_change(DeltaChange::Delete {
620            subject: "s2".into(),
621            predicate: "http://p/name".into(),
622            object: "Alice".into(),
623        });
624
625        let result = m.flush_changes();
626        assert_eq!(result.len(), 2);
627        assert!(result.contains_key("v_age"));
628        assert!(result.contains_key("v_name"));
629        // Mark-stale strategy: rows_changed reported as 0.
630        assert_eq!(result["v_age"], 0);
631        assert_eq!(result["v_name"], 0);
632    }
633
634    #[test]
635    fn test_maintainer_flush_clears_queue() {
636        let mut m = IncrementalViewMaintainer::new();
637        m.register_view(make_def("v", &["http://p/x"], true), rows(1));
638        m.queue_change(DeltaChange::Insert {
639            subject: "s".into(),
640            predicate: "http://p/x".into(),
641            object: "o".into(),
642        });
643        m.flush_changes();
644        // Flushing again with empty queue returns empty map.
645        let second = m.flush_changes();
646        assert!(second.is_empty());
647    }
648
649    #[test]
650    fn test_maintainer_total_rows_excludes_stale() {
651        let mut m = IncrementalViewMaintainer::new();
652        m.register_view(make_def("v1", &["http://p/age"], true), rows(5));
653        m.register_view(make_def("v2", &["http://p/name"], true), rows(3));
654
655        assert_eq!(m.total_rows(), 8);
656
657        // Invalidate v1.
658        m.apply_delta(DeltaChange::Insert {
659            subject: "s".into(),
660            predicate: "http://p/age".into(),
661            object: "1".into(),
662        });
663
664        assert_eq!(m.total_rows(), 3); // v1 is stale, only v2 counts.
665    }
666
667    #[test]
668    fn test_maintainer_list_views() {
669        let mut m = IncrementalViewMaintainer::new();
670        m.register_view(make_def("alpha", &[], true), rows(0));
671        m.register_view(make_def("beta", &[], true), rows(0));
672
673        let mut names = m.list_views();
674        names.sort_unstable();
675        assert_eq!(names, vec!["alpha", "beta"]);
676    }
677
678    #[test]
679    fn test_maintainer_replace_existing_view() {
680        let mut m = IncrementalViewMaintainer::new();
681        m.register_view(make_def("v", &["http://p/x"], true), rows(2));
682        // Re-register with the same name but different rows.
683        m.register_view(make_def("v", &["http://p/x"], true), rows(10));
684        assert_eq!(m.view_count(), 1);
685        assert_eq!(m.get_view("v").map(|v| v.rows.len()), Some(10));
686    }
687
688    // ---- ViewStalenessDetector ----
689
690    #[test]
691    fn test_staleness_detector_explicitly_stale() {
692        let def = make_def("v", &[], true);
693        let mut mv = MaterializedView::new(def, rows(1));
694        mv.is_stale = true;
695        // Any max_age_ms → still stale.
696        assert!(ViewStalenessDetector::is_stale(&mv, i64::MAX));
697    }
698
699    #[test]
700    fn test_staleness_detector_freshly_created_not_stale() {
701        let def = make_def("v", &[], true);
702        let mv = MaterializedView::new(def, rows(1));
703        // 1 hour should be well within freshness.
704        assert!(!ViewStalenessDetector::is_stale(&mv, 3_600_000));
705    }
706
707    #[test]
708    fn test_staleness_detector_zero_max_age_always_stale() {
709        let def = make_def("v", &[], true);
710        // Sleep briefly so that now_ms() - last_updated_ms > 0.
711        let mv = MaterializedView::new(def, rows(0));
712        thread::sleep(Duration::from_millis(5));
713        assert!(ViewStalenessDetector::is_stale(&mv, 0));
714    }
715
716    #[test]
717    fn test_staleness_detector_stale_views_filters_correctly() {
718        let def1 = make_def("v1", &[], true);
719        let def2 = make_def("v2", &[], true);
720        let mv1 = MaterializedView::new(def1, rows(0));
721        let mut mv2 = MaterializedView::new(def2, rows(0));
722        mv2.is_stale = true;
723
724        let stale = ViewStalenessDetector::stale_views(&[&mv1, &mv2], 3_600_000);
725        assert_eq!(stale.len(), 1);
726        assert!(stale[0].is_stale);
727    }
728}