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 Ok(QueryResult::CommandOk {
154 affected: analysed,
155 modified_catalog: true,
156 })
157 }
158
159 /// Walk a single table's rows once and (re-)populate per-column
160 /// stats. Drops the existing stats for `table` first so columns
161 /// that have been DROP-ed between ANALYZEs don't leave stale
162 /// rows.
163 fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
164 // v7.38.18 (S10) — take what this pass needs and release the
165 // catalog borrow. `active_catalog()` borrows the whole engine
166 // (it may hand back the transaction's shadow), where the old
167 // `self.catalog` borrowed one field, and the statistics store
168 // below is mutated through `self`.
169 let (schema, rows) = {
170 let table = self.active_catalog().get(table_name).ok_or_else(|| {
171 EngineError::Storage(StorageError::TableNotFound {
172 name: table_name.to_string(),
173 })
174 })?;
175 (table.schema().clone(), table.rows().clone())
176 };
177 let row_count = rows.len();
178 // For each column, collect (sorted) non-NULL textual values
179 // + count NULLs; then ask `statistics::build_histogram` to
180 // produce the 101 bounds and `estimate_n_distinct` the
181 // distinct count.
182 self.statistics.clear_table(table_name);
183 for (col_pos, col_schema) in schema.columns.iter().enumerate() {
184 // v6.2.0 skip: vector columns have their own stats
185 // shape (HNSW graph topology). v6.2 deliberation #1.
186 if matches!(col_schema.ty, DataType::Vector { .. }) {
187 continue;
188 }
189 let mut non_null_values: Vec<Value<'static>> = Vec::with_capacity(row_count);
190 let mut nulls: u64 = 0;
191 for row in rows.iter() {
192 match row.values.get(col_pos) {
193 Some(Value::Null) | None => nulls += 1,
194 Some(v) => non_null_values.push(v.clone()),
195 }
196 }
197 // Sort by type-aware ordering (Int as int, Text as
198 // lex, etc.) so histogram bounds reflect the column's
199 // natural order — not lexicographic on the string
200 // representation, which would put "9" after "49".
201 non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
202 let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
203 let null_frac = if row_count == 0 {
204 0.0
205 } else {
206 #[allow(clippy::cast_precision_loss)]
207 let f = nulls as f32 / row_count as f32;
208 f
209 };
210 let n_distinct = statistics::estimate_n_distinct(&non_null);
211 let histogram_bounds = statistics::build_histogram(&non_null);
212 self.statistics.set(
213 table_name.to_string(),
214 col_schema.name.clone(),
215 statistics::ColumnStats {
216 null_frac,
217 n_distinct,
218 histogram_bounds,
219 },
220 );
221 }
222 self.statistics.reset_modified(table_name);
223 // v6.7.0 — refresh the per-table cold_rows cache. Walk the
224 // BTree indices and count Cold locators (MAX across
225 // indices); store the result on the table. Surfaced via
226 // `spg_statistic.cold_row_count` (new column) and
227 // `spg_stat_segment.table_name` (new column).
228 let cold_count = {
229 let table = self
230 .active_catalog()
231 .get(table_name)
232 .expect("table still present");
233 table.count_cold_locators()
234 };
235 let table_mut = self
236 .active_catalog_mut()
237 .get_mut(table_name)
238 .expect("table still present");
239 table_mut.set_cold_row_count(cold_count);
240 Ok(())
241 }
242
243 /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
244 /// engine-layer compaction shim with the default
245 /// 4 MiB segment-size threshold. spg-server intercepts the
246 /// SQL before it reaches the engine on a server build —
247 /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
248 /// `Engine::compact_cold_segments_with_target` directly with
249 /// the env value, and persists every merged segment to
250 /// `<db>.spg/segments/`. This arm only fires for engine-only
251 /// callers (spg-embedded, lib tests); in that mode merged
252 /// segments live in memory and are dropped at process exit.
253 /// v7.37.17 (17.6 sibling) — `TRUNCATE [TABLE] <t>[, ...]
254 /// [RESTART IDENTITY]`. Clears every row from each named
255 /// table by dispatching to `Table::truncate()` (already exists
256 /// for internal callers). RESTART IDENTITY additionally resets
257 /// the table's associated sequence back to its start value.
258 pub(crate) fn exec_truncate(
259 &mut self,
260 tables: &[String],
261 _restart_identity: bool,
262 only: bool,
263 ) -> Result<QueryResult, EngineError> {
264 // RESTART IDENTITY is parsed but not honored yet — the
265 // SequenceDef doesn't expose a restart primitive on the
266 // storage side today. Accepted-and-no-op for pg_dump compat;
267 // real sequence reset lands with the sequence-lifecycle epic.
268 //
269 // v7.39 (round 647) — `ONLY` and the descent it turns off.
270 // Measured on PG18: a plain TRUNCATE of a parent empties its
271 // children too; `TRUNCATE ONLY <inheritance parent>` empties the
272 // parent alone; and `TRUNCATE ONLY <partitioned parent>` is not
273 // a no-op but an error, because a partitioned parent holds
274 // nothing and the spelling can only mean a mistake.
275 //
276 // The whole expansion sits inside this branch rather than beside
277 // it — rounds 641/643/644/646 each measured what an extra test
278 // in a function's body costs when the row loop is downstream.
279 let mut targets: Vec<String> = tables.to_vec();
280 if !tables.is_empty() {
281 for name in tables {
282 if only {
283 if crate::partition::is_partition_parent(self.active_catalog(), name) {
284 return Err(EngineError::Unsupported(alloc::format!(
285 "cannot truncate only a partitioned table"
286 )));
287 }
288 continue;
289 }
290 let mut frontier = alloc::vec![name.clone()];
291 while let Some(cur) = frontier.pop() {
292 for kid in crate::partition::children_of_parent(self.active_catalog(), &cur) {
293 frontier.push(kid.clone());
294 targets.push(kid);
295 }
296 }
297 }
298 }
299 let mut affected: usize = 0;
300 for name in &targets {
301 let cat = self.active_catalog_mut();
302 let Some(t) = cat.get_mut(name) else {
303 // v7.39 (read01 round 50) — PG says "relation" for TRUNCATE
304 // (only DROP TABLE says "table"). 42P01 at the wire.
305 return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
306 "relation {name:?} does not exist"
307 ))));
308 };
309 affected = affected.saturating_add(t.row_count());
310 t.truncate();
311 }
312 Ok(QueryResult::CommandOk {
313 affected,
314 modified_catalog: false,
315 })
316 }
317
318 pub(crate) fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
319 let target = COMPACTION_TARGET_DEFAULT_BYTES;
320 let reports = self.compact_cold_segments_with_target(target)?;
321 let columns = alloc::vec![
322 ColumnSchema::new("table_name", DataType::Text, false),
323 ColumnSchema::new("index_name", DataType::Text, false),
324 ColumnSchema::new("sources_merged", DataType::BigInt, false),
325 ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
326 ColumnSchema::new("merged_rows", DataType::BigInt, false),
327 ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
328 ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
329 ];
330 let rows: Vec<Row<'static>> = reports
331 .into_iter()
332 .map(|(tname, iname, report)| {
333 Row::new(alloc::vec![
334 Value::text(tname),
335 Value::text(iname),
336 Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
337 Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
338 Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
339 Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
340 Value::BigInt(
341 i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
342 ),
343 ])
344 })
345 .collect();
346 Ok(QueryResult::Rows { columns, rows })
347 }
348
349 /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
350 /// driving every BTree index on every user table. Returns one
351 /// `(table, index, report)` triple for each merge that
352 /// actually happened (no-op (table, index) pairs are filtered
353 /// out so callers can size persist-side work to the live
354 /// merges). Caller is responsible for persisting each
355 /// `report.merged_segment_bytes` and updating the on-disk
356 /// segment registry; engine layer is no_std and never
357 /// touches disk.
358 ///
359 /// Marks every touched table's cached `cold_row_count` stale
360 /// — compaction GC'd some shadowed rows, so the count must be
361 /// re-derived on the next ANALYZE.
362 pub fn compact_cold_segments_with_target(
363 &mut self,
364 target_segment_bytes: u64,
365 ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
366 let table_names = self.active_catalog().table_names();
367 let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
368 for tname in table_names {
369 if is_internal_table_name(&tname) {
370 continue;
371 }
372 let idx_names: Vec<String> = {
373 let Some(t) = self.active_catalog().get(&tname) else {
374 continue;
375 };
376 t.indices()
377 .iter()
378 .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
379 .map(|i| i.name.clone())
380 .collect()
381 };
382 for iname in idx_names {
383 let report = self
384 .active_catalog_mut()
385 .compact_cold_segments(&tname, &iname, target_segment_bytes)
386 .map_err(EngineError::Storage)?;
387 if report.merged_segment_id.is_some() {
388 if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
389 t.mark_cold_row_count_stale();
390 }
391 reports.push((tname.clone(), iname, report));
392 }
393 }
394 }
395 Ok(reports)
396 }
397}