Skip to main content

spg_engine/
maintenance.rs

1//! Table-maintenance executors: `ANALYZE` (re-stat) and
2//! `COMPACT COLD SEGMENTS` (cold-segment merge). Split out of
3//! `lib.rs` (cut 19); verbatim move, the only edit beyond
4//! visibility is reuniting the `exec_compact_cold_segments` doc,
5//! whose first half had drifted above `set_session_param`.
6
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9
10use spg_storage::{ColumnSchema, CompactReport, DataType, IndexKind, Row, StorageError, Value};
11
12use crate::{
13    COMPACTION_TARGET_DEFAULT_BYTES, Engine, EngineError, QueryResult, canonical_value_repr,
14    is_internal_table_name, sort_values_for_histogram, statistics,
15};
16
17impl Engine {
18    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
19    /// every user table; `ANALYZE <name>` re-stats one. For each
20    /// target table, single-pass scan + per-column histogram +
21    /// `null_frac` + `n_distinct`. Replaces the table's prior
22    /// stats; resets the modified-row counter.
23    ///
24    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
25    /// can add reservoir sampling at the > 100 K-row mark; not a
26    /// scope blocker for the current commit since rows ≤ 100 K
27    /// analyse in milliseconds.
28    /// v7.39 (round 320, V53) — `DISCARD { ALL | PLANS | SEQUENCES | TEMP }`.
29    /// pgbouncer sends `DISCARD ALL` between pooled client sessions so the
30    /// next client sees a clean connection. It used to be swallowed as
31    /// "dump noise" on the theory that SPG had no per-connection state
32    /// worth discarding — untrue since round 279 gave every connection its
33    /// own session bag (GUC overrides, prepared statements, large-object
34    /// descriptors). A no-op meant one pooled client's state leaked to the
35    /// next one.
36    ///
37    /// PG 18.4 measured: `SET application_name='x'; PREPARE p …;
38    /// DISCARD ALL` leaves application_name back at its startup value and
39    /// `EXECUTE p` failing with "prepared statement \"p\" does not
40    /// exist"; inside a transaction block it is
41    /// `ERROR: DISCARD ALL cannot run inside a transaction block`.
42    ///
43    /// v7.39 (round 321, V54) — cursors are closed here too, as PG's
44    /// DISCARD ALL does. Round 320 had to leave them alone because the
45    /// cursor table was process-wide: closing "all" from one connection
46    /// would have closed another's. They live in the session bag now.
47    pub(crate) fn exec_discard(
48        &mut self,
49        target: spg_sql::ast::DiscardTarget,
50    ) -> Result<QueryResult, EngineError> {
51        use spg_sql::ast::DiscardTarget;
52        if matches!(target, DiscardTarget::All | DiscardTarget::Temp)
53            && self.current_tx.is_some_and(|tx| self.is_tx_open(tx))
54        {
55            return Err(EngineError::Unsupported(alloc::format!(
56                "DISCARD {target} cannot run inside a transaction block"
57            )));
58        }
59        match target {
60            DiscardTarget::All => {
61                self.reset_all_gucs();
62                self.prepared_statements.clear();
63                self.lo_descriptors.clear();
64                self.lo_next_fd = 0;
65                self.cursors.clear();
66                self.listen_channels.clear();
67                self.plan_cache.clear();
68                self.refresh_render_style();
69            }
70            DiscardTarget::Plans => self.plan_cache.clear(),
71            // SPG has neither temp tables nor per-session sequence state
72            // (`currval` reads the catalog), so there is nothing of either
73            // kind to throw away. Accepted so a client's sequence of
74            // DISCARDs runs, and tagged with the target it named.
75            DiscardTarget::Sequences | DiscardTarget::Temp => {}
76        }
77        Ok(QueryResult::CommandOk {
78            affected: 0,
79            modified_catalog: false,
80        })
81    }
82
83    pub(crate) fn exec_analyze(
84        &mut self,
85        target: Option<&str>,
86    ) -> Result<QueryResult, EngineError> {
87        // v7.38 元机制 D acceptor — `SPG_TEST_STATS_FROZEN=1` turns
88        // ANALYZE into a no-op so a test's statistic-version snapshot
89        // is stable across sessions. Pairs with the plan-cache
90        // version-aware invalidation: freezing stats also freezes
91        // plan reuse, which is what regression tests want when
92        // proving "same SQL, same plan".
93        if self.env_cfg().stats_frozen {
94            return Ok(QueryResult::CommandOk {
95                affected: 0,
96                modified_catalog: false,
97            });
98        }
99        let names: Vec<String> = if let Some(name) = target {
100            // Verify the table exists; surface a clear error if not.
101            // v7.38.18 (S10) — the ACTIVE catalog, which includes the
102            // open transaction's shadow. A multi-statement simple query
103            // is an implicit transaction, so
104            //
105            //   CREATE TABLE t (k INT); INSERT INTO t VALUES (1); ANALYZE t;
106            //
107            // sent as ONE string answered `relation "t" does not exist`
108            // — while the INSERT in the same string had just succeeded.
109            // Seven other statement kinds in that position were already
110            // right; `ANALYZE` read the committed catalog alone.
111            if self.active_catalog().get(name).is_none() {
112                return Err(EngineError::Storage(StorageError::TableNotFound {
113                    name: name.to_string(),
114                }));
115            }
116            alloc::vec![name.to_string()]
117        } else {
118            // Same reason: a bare ANALYZE must cover the tables this
119            // transaction created, not only the committed ones.
120            self.active_catalog()
121                .table_names()
122                .into_iter()
123                .filter(|n| !is_internal_table_name(n))
124                .collect()
125        };
126        let mut analysed = 0usize;
127        let now_us = self.clock.map(|f| f());
128        for table_name in &names {
129            self.analyze_one_table(table_name)?;
130            // v7.39 (pg_stat knife C) — stamp last_analyze.
131            if let Some(us) = now_us
132                && let Some(t) = self.active_catalog_mut().get_mut(table_name)
133            {
134                t.stamp_analyze(us);
135            }
136            analysed += 1;
137        }
138        // v6.3.1 — plan cache invalidation. Bump stats version so
139        // future lookups see the new generation, and selectively
140        // evict every plan whose `source_tables` overlap with the
141        // ANALYZE target set. Bare ANALYZE (all tables) clears the
142        // whole cache.
143        if analysed > 0 {
144            self.statistics.bump_version();
145            if target.is_some() {
146                for t in &names {
147                    self.plan_cache.evict_referencing(t);
148                }
149            } else {
150                self.plan_cache.clear();
151            }
152        }
153        // v7.39.9 — MySQL answers `ANALYZE TABLE` with a RESULT SET, not
154        // a command tag: measured on 9.7.2, one row per table carrying
155        // `bench.m1  analyze  status  OK`. A client reading those rows
156        // gets nothing from a command tag, so the shape follows the
157        // dialect.
158        if self.speaks_mysql {
159            let cols = alloc::vec![
160                spg_storage::ColumnSchema::new("Table", spg_storage::DataType::Text, false),
161                spg_storage::ColumnSchema::new("Op", spg_storage::DataType::Text, false),
162                spg_storage::ColumnSchema::new("Msg_type", spg_storage::DataType::Text, false),
163                spg_storage::ColumnSchema::new("Msg_text", spg_storage::DataType::Text, false),
164            ];
165            let db = self.session_param("spg.database").unwrap_or_default();
166            let rows = names
167                .iter()
168                .map(|n| {
169                    spg_storage::Row::new(alloc::vec![
170                        spg_storage::Value::text(if db.is_empty() {
171                            n.clone()
172                        } else {
173                            alloc::format!("{db}.{n}")
174                        }),
175                        spg_storage::Value::text("analyze"),
176                        spg_storage::Value::text("status"),
177                        spg_storage::Value::text("OK"),
178                    ])
179                })
180                .collect();
181            return Ok(QueryResult::Rows {
182                columns: cols,
183                rows,
184            });
185        }
186        Ok(QueryResult::CommandOk {
187            affected: analysed,
188            modified_catalog: true,
189        })
190    }
191
192    /// Walk a single table's rows once and (re-)populate per-column
193    /// stats. Drops the existing stats for `table` first so columns
194    /// that have been DROP-ed between ANALYZEs don't leave stale
195    /// rows.
196    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
197        // v7.38.18 (S10) — take what this pass needs and release the
198        // catalog borrow. `active_catalog()` borrows the whole engine
199        // (it may hand back the transaction's shadow), where the old
200        // `self.catalog` borrowed one field, and the statistics store
201        // below is mutated through `self`.
202        let (schema, rows) = {
203            let table = self.active_catalog().get(table_name).ok_or_else(|| {
204                EngineError::Storage(StorageError::TableNotFound {
205                    name: table_name.to_string(),
206                })
207            })?;
208            (table.schema().clone(), table.rows().clone())
209        };
210        let row_count = rows.len();
211        // For each column, collect (sorted) non-NULL textual values
212        // + count NULLs; then ask `statistics::build_histogram` to
213        // produce the 101 bounds and `estimate_n_distinct` the
214        // distinct count.
215        self.statistics.clear_table(table_name);
216        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
217            // v6.2.0 skip: vector columns have their own stats
218            // shape (HNSW graph topology). v6.2 deliberation #1.
219            if matches!(col_schema.ty, DataType::Vector { .. }) {
220                continue;
221            }
222            let mut non_null_values: Vec<Value<'static>> = Vec::with_capacity(row_count);
223            let mut nulls: u64 = 0;
224            for row in rows.iter() {
225                match row.values.get(col_pos) {
226                    Some(Value::Null) | None => nulls += 1,
227                    Some(v) => non_null_values.push(v.clone()),
228                }
229            }
230            // Sort by type-aware ordering (Int as int, Text as
231            // lex, etc.) so histogram bounds reflect the column's
232            // natural order — not lexicographic on the string
233            // representation, which would put "9" after "49".
234            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
235            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
236            let null_frac = if row_count == 0 {
237                0.0
238            } else {
239                #[allow(clippy::cast_precision_loss)]
240                let f = nulls as f32 / row_count as f32;
241                f
242            };
243            let n_distinct = statistics::estimate_n_distinct(&non_null);
244            let histogram_bounds = statistics::build_histogram(&non_null);
245            self.statistics.set(
246                table_name.to_string(),
247                col_schema.name.clone(),
248                statistics::ColumnStats {
249                    null_frac,
250                    n_distinct,
251                    histogram_bounds,
252                },
253            );
254        }
255        self.statistics.reset_modified(table_name);
256        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
257        // BTree indices and count Cold locators (MAX across
258        // indices); store the result on the table. Surfaced via
259        // `spg_statistic.cold_row_count` (new column) and
260        // `spg_stat_segment.table_name` (new column).
261        let cold_count = {
262            let table = self
263                .active_catalog()
264                .get(table_name)
265                .expect("table still present");
266            table.count_cold_locators()
267        };
268        let table_mut = self
269            .active_catalog_mut()
270            .get_mut(table_name)
271            .expect("table still present");
272        table_mut.set_cold_row_count(cold_count);
273        Ok(())
274    }
275
276    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
277    /// engine-layer compaction shim with the default
278    /// 4 MiB segment-size threshold. spg-server intercepts the
279    /// SQL before it reaches the engine on a server build —
280    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
281    /// `Engine::compact_cold_segments_with_target` directly with
282    /// the env value, and persists every merged segment to
283    /// `<db>.spg/segments/`. This arm only fires for engine-only
284    /// callers (spg-embedded, lib tests); in that mode merged
285    /// segments live in memory and are dropped at process exit.
286    /// v7.37.17 (17.6 sibling) — `TRUNCATE [TABLE] <t>[, ...]
287    /// [RESTART IDENTITY]`. Clears every row from each named
288    /// table by dispatching to `Table::truncate()` (already exists
289    /// for internal callers). RESTART IDENTITY additionally resets
290    /// the table's associated sequence back to its start value.
291    pub(crate) fn exec_truncate(
292        &mut self,
293        tables: &[String],
294        _restart_identity: bool,
295        only: bool,
296    ) -> Result<QueryResult, EngineError> {
297        // RESTART IDENTITY is parsed but not honored yet — the
298        // SequenceDef doesn't expose a restart primitive on the
299        // storage side today. Accepted-and-no-op for pg_dump compat;
300        // real sequence reset lands with the sequence-lifecycle epic.
301        //
302        // v7.39 (round 647) — `ONLY` and the descent it turns off.
303        // Measured on PG18: a plain TRUNCATE of a parent empties its
304        // children too; `TRUNCATE ONLY <inheritance parent>` empties the
305        // parent alone; and `TRUNCATE ONLY <partitioned parent>` is not
306        // a no-op but an error, because a partitioned parent holds
307        // nothing and the spelling can only mean a mistake.
308        //
309        // The whole expansion sits inside this branch rather than beside
310        // it — rounds 641/643/644/646 each measured what an extra test
311        // in a function's body costs when the row loop is downstream.
312        let mut targets: Vec<String> = tables.to_vec();
313        if !tables.is_empty() {
314            for name in tables {
315                if only {
316                    if crate::partition::is_partition_parent(self.active_catalog(), name) {
317                        return Err(EngineError::Unsupported(alloc::format!(
318                            "cannot truncate only a partitioned table"
319                        )));
320                    }
321                    continue;
322                }
323                let mut frontier = alloc::vec![name.clone()];
324                while let Some(cur) = frontier.pop() {
325                    for kid in crate::partition::children_of_parent(self.active_catalog(), &cur) {
326                        frontier.push(kid.clone());
327                        targets.push(kid);
328                    }
329                }
330            }
331        }
332        let mut affected: usize = 0;
333        for name in &targets {
334            let cat = self.active_catalog_mut();
335            let Some(t) = cat.get_mut(name) else {
336                // v7.39 (read01 round 50) — PG says "relation" for TRUNCATE
337                // (only DROP TABLE says "table"). 42P01 at the wire.
338                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
339                    "relation {name:?} does not exist"
340                ))));
341            };
342            affected = affected.saturating_add(t.row_count());
343            t.truncate();
344        }
345        Ok(QueryResult::CommandOk {
346            affected,
347            modified_catalog: false,
348        })
349    }
350
351    pub(crate) fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
352        let target = COMPACTION_TARGET_DEFAULT_BYTES;
353        let reports = self.compact_cold_segments_with_target(target)?;
354        let columns = alloc::vec![
355            ColumnSchema::new("table_name", DataType::Text, false),
356            ColumnSchema::new("index_name", DataType::Text, false),
357            ColumnSchema::new("sources_merged", DataType::BigInt, false),
358            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
359            ColumnSchema::new("merged_rows", DataType::BigInt, false),
360            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
361            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
362        ];
363        let rows: Vec<Row<'static>> = reports
364            .into_iter()
365            .map(|(tname, iname, report)| {
366                Row::new(alloc::vec![
367                    Value::text(tname),
368                    Value::text(iname),
369                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
370                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
371                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
372                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
373                    Value::BigInt(
374                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
375                    ),
376                ])
377            })
378            .collect();
379        Ok(QueryResult::Rows { columns, rows })
380    }
381
382    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
383    /// driving every BTree index on every user table. Returns one
384    /// `(table, index, report)` triple for each merge that
385    /// actually happened (no-op (table, index) pairs are filtered
386    /// out so callers can size persist-side work to the live
387    /// merges). Caller is responsible for persisting each
388    /// `report.merged_segment_bytes` and updating the on-disk
389    /// segment registry; engine layer is no_std and never
390    /// touches disk.
391    ///
392    /// Marks every touched table's cached `cold_row_count` stale
393    /// — compaction GC'd some shadowed rows, so the count must be
394    /// re-derived on the next ANALYZE.
395    pub fn compact_cold_segments_with_target(
396        &mut self,
397        target_segment_bytes: u64,
398    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
399        let table_names = self.active_catalog().table_names();
400        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
401        for tname in table_names {
402            if is_internal_table_name(&tname) {
403                continue;
404            }
405            let idx_names: Vec<String> = {
406                let Some(t) = self.active_catalog().get(&tname) else {
407                    continue;
408                };
409                t.indices()
410                    .iter()
411                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
412                    .map(|i| i.name.clone())
413                    .collect()
414            };
415            for iname in idx_names {
416                let report = self
417                    .active_catalog_mut()
418                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
419                    .map_err(EngineError::Storage)?;
420                if report.merged_segment_id.is_some() {
421                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
422                        t.mark_cold_row_count_stale();
423                    }
424                    reports.push((tname.clone(), iname, report));
425                }
426            }
427        }
428        Ok(reports)
429    }
430}