reddb_server/runtime/impl_materialized_view.rs
1//! Runtime materialized-view refresh and retention sweep.
2//!
3//! Extracted verbatim from `impl_core.rs` (impl_core slice 6/10, issue #1627).
4//! Houses the background-tick surface for scheduled view refresh and retention
5//! sweeps:
6//!
7//! - `materialized_view_metadata` — snapshot of every registered view's
8//! runtime state (feeds `red.materialized_views`).
9//! - `retention_sweeper_snapshot` — snapshot of every sweeper's state
10//! (feeds `red.retention`).
11//! - `sweep_retention_tick` — one tick of the retention sweeper.
12//! - `refresh_due_materialized_views` — claim and refresh views due for
13//! a scheduled refresh.
14use super::*;
15
16impl RedDBRuntime {
17 /// Snapshot of every registered materialized view's runtime
18 /// state — feeds the `red.materialized_views` virtual table.
19 /// Issue #583 slice 10.
20 pub fn materialized_view_metadata(
21 &self,
22 ) -> Vec<crate::storage::cache::result::MaterializedViewMetadata> {
23 // Issue #595 slice 9c — `current_row_count` is now scraped
24 // live from the backing collection rather than read from the
25 // cache slot. Mirrors the slice-10 invariant on
26 // `queue_pending_gauge` in #527: the live store is the source
27 // of truth, the cache slot only carries last-refresh telemetry
28 // (timing, error, refresh cadence).
29 let store = self.inner.db.store();
30 let mut entries = self.inner.materialized_views.read().metadata();
31 for entry in &mut entries {
32 if let Some(manager) = store.get_collection(&entry.name) {
33 entry.current_row_count = manager.count() as u64;
34 }
35 }
36 entries
37 }
38
39 /// Drive scheduled refreshes for materialized views with a
40 /// `REFRESH EVERY <duration>` clause. Called from the background
41 /// scheduler thread (and from unit tests with a fake clock via
42 /// `claim_due_at`). Each invocation atomically claims the set of
43 /// due views (so two concurrent ticks never double-fire the same
44 /// view) and runs each refresh through the standard execution
45 /// path — failures are captured in `last_error` and the prior
46 /// content stays intact. Issue #583 slice 10.
47 /// Snapshot of every tracked retention sweeper state — feeds the
48 /// sweeper observability columns on `red.retention`.
49 pub(crate) fn retention_sweeper_snapshot(
50 &self,
51 ) -> Vec<(String, crate::runtime::retention_sweeper::SweeperState)> {
52 self.inner.retention_sweeper.read().snapshot()
53 }
54
55 /// Drive one tick of the retention sweeper. Mutable and append-only
56 /// collections physically delete at most `batch_size` expired rows across
57 /// collections through the regular WAL path. Records the counters that
58 /// `red.retention` exposes.
59 /// Called from the background sweeper thread; safe to invoke directly
60 /// from tests with a small batch size to drain rows deterministically.
61 /// Issue #584 slice 12.
62 ///
63 /// Deletes are issued as `DELETE FROM <collection> WHERE
64 /// <ts_column> < <cutoff>` through the standard `execute_query`
65 /// chokepoint so WAL participation and snapshot guards apply
66 /// exactly as for a user-issued DELETE — replicas replay the
67 /// sweeper's deletes via the same WAL stream with no special
68 /// handling on the replication side.
69 ///
70 /// Scans inspect at most `batch_size` physical positions across
71 /// collections and resume from their previous position on the next tick.
72 pub fn sweep_retention_tick(&self, batch_size: usize) {
73 if batch_size == 0 {
74 return;
75 }
76 let now_ms = std::time::SystemTime::now()
77 .duration_since(std::time::UNIX_EPOCH)
78 .map(|d| d.as_millis() as u64)
79 .unwrap_or(0);
80
81 let store = self.inner.db.store();
82 let mut collections = store.list_collections();
83 collections.sort();
84 if let Some(next) = self.inner.retention_sweeper.read().next_collection() {
85 if let Some(start) = collections.iter().position(|name| name == next) {
86 collections.rotate_left(start);
87 }
88 }
89 let mut remaining = batch_size;
90 for (index, name) in collections.iter().enumerate() {
91 let Some(contract) = self.inner.db.collection_contract(name) else {
92 continue;
93 };
94 let Some(retention_ms) = contract.retention_duration_ms else {
95 continue;
96 };
97 let Some(ts_column) =
98 crate::runtime::retention_filter::resolve_timestamp_column(&contract)
99 else {
100 continue;
101 };
102 if store.get_collection(name).is_none() {
103 continue;
104 }
105 if remaining == 0 {
106 continue;
107 }
108 let cutoff = super::retention_sweeper::cutoff_ms(now_ms, retention_ms);
109
110 let candidates = self
111 .inner
112 .retention_sweeper
113 .write()
114 .scan_tick(&store, name, remaining);
115 let (candidates, scanned) = match candidates {
116 Ok(result) => result,
117 Err(_) => {
118 self.inner
119 .retention_sweeper
120 .write()
121 .record_tick(name, 0, 1, now_ms);
122 // The failed scan may already have consumed its budget.
123 // Retry other collections on a later tick.
124 remaining = 0;
125 self.inner
126 .retention_sweeper
127 .write()
128 .resume_at(collections[(index + 1) % collections.len()].clone());
129 continue;
130 }
131 };
132 remaining -= scanned;
133 if remaining == 0 && !collections.is_empty() {
134 self.inner
135 .retention_sweeper
136 .write()
137 .resume_at(collections[(index + 1) % collections.len()].clone());
138 }
139 if candidates.is_empty() {
140 self.inner
141 .retention_sweeper
142 .write()
143 .record_tick(name, 0, 1, now_ms);
144 continue;
145 }
146
147 let stmt = format!("DELETE FROM {} WHERE {} < {}", name, ts_column, cutoff);
148 let result = super::retention_sweeper::with_delete_candidates(
149 candidates,
150 name,
151 &ts_column,
152 || self.execute_query(&stmt),
153 );
154 let (deleted, pending) = match result {
155 Ok(r) => (r.affected_rows, u64::from(scanned > 0)),
156 Err(_) => (0, 1),
157 };
158
159 self.inner
160 .retention_sweeper
161 .write()
162 .record_tick(name, deleted, pending, now_ms);
163 }
164 }
165
166 pub fn refresh_due_materialized_views(&self) {
167 let due = {
168 let mut cache = self.inner.materialized_views.write();
169 cache.claim_due_at(std::time::Instant::now())
170 };
171 for name in due {
172 // Round-trip through `execute_query` (rather than the
173 // expression-only `execute_query_expr` entry, which
174 // explicitly rejects DDL/maintenance statements). Failures
175 // are captured inside the RefreshMaterializedView handler
176 // via `record_refresh_failure`; the scheduler ignores the
177 // Result so one bad view doesn't halt the loop.
178 let stmt = format!("REFRESH MATERIALIZED VIEW {}", name);
179 let _ = self.execute_query(&stmt);
180 }
181 }
182}