Skip to main content

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    /// Reclaim per-entity `WITH TTL` / `WITH EXPIRES AT` deadlines without a
167    /// metadata or collection scan. Current paged files restore the deadline
168    /// index from the resume trailer; legacy files build it in bounded ticks.
169    /// Only the primary materializes deletes, and every batch uses the regular
170    /// DELETE/WAL path.
171    pub fn sweep_individual_ttl_tick(&self, batch_size: usize) -> u64 {
172        let now_ms = std::time::SystemTime::now()
173            .duration_since(std::time::UNIX_EPOCH)
174            .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
175            .unwrap_or(0);
176        self.sweep_individual_ttl_tick_at(batch_size, now_ms)
177    }
178
179    pub(crate) fn sweep_individual_ttl_tick_at(&self, batch_size: usize, now_ms: u64) -> u64 {
180        if batch_size == 0 || self.inner.db.is_replica_role() {
181            return 0;
182        }
183        if self
184            .check_write(crate::runtime::write_gate::WriteKind::Maintenance)
185            .is_err()
186        {
187            return 0;
188        }
189
190        let store = self.inner.db.store();
191        let batch_size = if store.config().resident_memory_bytes_max.is_some() {
192            batch_size.min(super::retention_sweeper::DISK_RESIDENT_SWEEPER_BATCH)
193        } else {
194            batch_size
195        };
196        let mut collections = store.list_collections();
197        collections.sort();
198        if collections.is_empty() {
199            return 0;
200        }
201        if let Some(next) = self.inner.retention_sweeper.read().next_ttl_collection() {
202            if let Some(start) = collections.iter().position(|name| name == next) {
203                collections.rotate_left(start);
204            }
205        }
206
207        let mut remaining = batch_size;
208        let mut swept_total = 0u64;
209        for (index, collection) in collections.iter().enumerate() {
210            if remaining == 0 {
211                break;
212            }
213            let Some(manager) = store.get_collection(collection) else {
214                continue;
215            };
216            let started = std::time::Instant::now();
217            if !manager.metadata_expiry_projection_ready() {
218                let complete = self
219                    .inner
220                    .retention_sweeper
221                    .write()
222                    .rebuild_ttl_projection_tick(&store, collection, batch_size)
223                    .map(|(_, complete)| complete)
224                    .unwrap_or(false);
225                if !complete {
226                    let duration_ms =
227                        started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
228                    let mut state = self.inner.retention_sweeper.write();
229                    state.record_ttl_tick(collection, 0, 1, duration_ms, now_ms);
230                    state.resume_ttl_at(collections[(index + 1) % collections.len()].clone());
231                    break;
232                }
233            }
234            let mut candidates = manager.expired_metadata_ids(now_ms, remaining.saturating_add(1));
235            let has_more = candidates.len() > remaining;
236            candidates.truncate(remaining);
237            if candidates.is_empty() {
238                continue;
239            }
240            remaining = remaining.saturating_sub(candidates.len());
241
242            let statement = format!(
243                "DELETE FROM {} WHERE rid >= 0",
244                quote_maintenance_identifier(collection)
245            );
246            let result = super::retention_sweeper::with_ttl_delete_candidates(
247                candidates,
248                collection,
249                now_ms,
250                || self.execute_query(&statement),
251            );
252            let (swept, pending_estimate) = match result {
253                Ok(result) => (result.affected_rows, u64::from(has_more)),
254                Err(_) => (0, 1),
255            };
256            swept_total = swept_total.saturating_add(swept);
257            let duration_ms = started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
258            self.inner.retention_sweeper.write().record_ttl_tick(
259                collection,
260                swept,
261                pending_estimate,
262                duration_ms,
263                now_ms,
264            );
265            if remaining == 0 {
266                self.inner
267                    .retention_sweeper
268                    .write()
269                    .resume_ttl_at(collections[(index + 1) % collections.len()].clone());
270            }
271        }
272        swept_total
273    }
274
275    pub fn refresh_due_materialized_views(&self) {
276        let due = {
277            let mut cache = self.inner.materialized_views.write();
278            cache.claim_due_at(std::time::Instant::now())
279        };
280        for name in due {
281            // Round-trip through `execute_query` (rather than the
282            // expression-only `execute_query_expr` entry, which
283            // explicitly rejects DDL/maintenance statements). Failures
284            // are captured inside the RefreshMaterializedView handler
285            // via `record_refresh_failure`; the scheduler ignores the
286            // Result so one bad view doesn't halt the loop.
287            let stmt = format!("REFRESH MATERIALIZED VIEW {}", name);
288            let _ = self.execute_query(&stmt);
289        }
290    }
291}
292
293fn quote_maintenance_identifier(identifier: &str) -> String {
294    format!("\"{}\"", identifier.replace('"', "\"\""))
295}