dsfb_database/live_mysql/queries.rs
1//! Query allow-list for the live MySQL adapter.
2//!
3//! This file is the MySQL analogue of `src/live/queries.rs`. Every
4//! SQL statement the live-mysql adapter can ever issue is enumerated
5//! by [`AllowedMySqlQuery`] and maps to a `'static` SQL string via
6//! [`AllowedMySqlQuery::sql`]. The concatenated SQL texts are
7//! SHA-256-pinned by `tests/live_query_allowlist_lock_mysql.rs`: any
8//! edit — even an added comment — forces an intentional lock bump
9//! that must be co-authored with the paper's §Live-Eval MySQL
10//! subsection and the `spec/permissions.mysql.sql` manifest.
11//!
12//! All four variants are pure `SELECT` against `performance_schema`
13//! and `information_schema`. None touch user tables. None issue DDL,
14//! DML, or advisory locks. This is the statement-level layer of the
15//! code-audit contract documented in [`crate::live_mysql`].
16//!
17//! The residual-class mapping mirrors the PostgreSQL path:
18//! * DigestSnapshot -> PlanRegression / WorkloadPhase
19//! * ThreadsSnapshot -> Contention (wait-event samples)
20//! * MetadataLocksSnapshot -> Contention (per-object lock waits)
21//! * BufferPoolSnapshot -> CacheIo
22
23/// Closed enumeration of every SQL statement the live-mysql adapter
24/// will ever execute. Adding a variant is a reviewable change that
25/// simultaneously breaks [`Self::sql_concat_for_lock`] and therefore
26/// the allow-list lock test.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum AllowedMySqlQuery {
29 /// Per-digest cumulative latency / call / row counters.
30 /// Source: `performance_schema.events_statements_summary_by_digest`
31 /// (MySQL 5.6+; column names stable through 8.4).
32 /// Residual classes emitted: PlanRegression, WorkloadPhase.
33 DigestSnapshot,
34 /// Per-thread wait-event sample.
35 /// Source: `performance_schema.threads` (MySQL 5.6+).
36 /// Residual class emitted: Contention.
37 ThreadsSnapshot,
38 /// Per-object metadata-lock wait snapshot.
39 /// Source: `performance_schema.metadata_locks` (MySQL 5.7+).
40 /// Residual class emitted: Contention (no PostgreSQL analog).
41 MetadataLocksSnapshot,
42 /// Per-pool InnoDB buffer-pool cumulative counters.
43 /// Source: `information_schema.innodb_buffer_pool_stats`
44 /// (MySQL 5.6+).
45 /// Residual class emitted: CacheIo.
46 BufferPoolSnapshot,
47}
48
49impl AllowedMySqlQuery {
50 /// Every variant the adapter knows about. Used by the lock test
51 /// and by the scraper to enumerate a full poll cycle in a
52 /// deterministic order.
53 pub const ALL: [AllowedMySqlQuery; 4] = [
54 Self::DigestSnapshot,
55 Self::ThreadsSnapshot,
56 Self::MetadataLocksSnapshot,
57 Self::BufferPoolSnapshot,
58 ];
59
60 /// Pinned SQL text for this variant. Every string is a `SELECT`
61 /// against `performance_schema` or `information_schema`. Any edit
62 /// invalidates the lock test and forces a paired paper update.
63 pub fn sql(&self) -> &'static str {
64 match self {
65 Self::DigestSnapshot => {
66 // MD5 of the digest canonicalises the identifier so
67 // the emitted residual stream contains no raw digest
68 // text. Analogous to the md5(queryid::text) choice in
69 // the PostgreSQL path.
70 "SELECT \
71 UNIX_TIMESTAMP(NOW(6)) AS snapshot_t, \
72 MD5(DIGEST) AS digest_id, \
73 COUNT_STAR AS calls, \
74 SUM_TIMER_WAIT / 1000000000.0 AS total_exec_time_ms \
75 FROM performance_schema.events_statements_summary_by_digest \
76 WHERE DIGEST IS NOT NULL"
77 }
78 Self::ThreadsSnapshot => {
79 // No query-text columns, no client-host columns.
80 // Counts wait-event samples per category in the
81 // distiller, so the PII surface collapses to "how
82 // many sessions are waiting on which wait-event
83 // class".
84 "SELECT \
85 UNIX_TIMESTAMP(NOW(6)) AS snapshot_t, \
86 COALESCE(PROCESSLIST_STATE, 'None') AS wait_event_type, \
87 COALESCE(PROCESSLIST_COMMAND, 'None') AS wait_event, \
88 COUNT(*) AS n_threads \
89 FROM performance_schema.threads \
90 WHERE PROCESSLIST_ID IS NOT NULL \
91 GROUP BY PROCESSLIST_STATE, PROCESSLIST_COMMAND"
92 }
93 Self::MetadataLocksSnapshot => {
94 // Aggregates per-status counts to avoid emitting raw
95 // object identifiers. A pure read: performance_schema
96 // exposes lock metadata via SELECT, not a lock-taking
97 // call.
98 "SELECT \
99 UNIX_TIMESTAMP(NOW(6)) AS snapshot_t, \
100 COALESCE(LOCK_STATUS, 'None') AS lock_status, \
101 COALESCE(LOCK_TYPE, 'None') AS lock_type, \
102 COUNT(*) AS n_waiters \
103 FROM performance_schema.metadata_locks \
104 GROUP BY LOCK_STATUS, LOCK_TYPE"
105 }
106 Self::BufferPoolSnapshot => {
107 // Per-pool cache counters. InnoDB exposes them via
108 // information_schema; the adapter consumes the delta
109 // between adjacent snapshots in the distiller.
110 "SELECT \
111 UNIX_TIMESTAMP(NOW(6)) AS snapshot_t, \
112 POOL_ID AS pool_id, \
113 PAGES_DATA AS pages_data, \
114 PAGES_MISC AS pages_misc, \
115 PAGES_FREE AS pages_free, \
116 PAGES_MADE_YOUNG AS pages_made_young, \
117 PAGES_READ AS pages_read, \
118 PAGES_CREATED AS pages_created, \
119 PAGES_WRITTEN AS pages_written \
120 FROM information_schema.innodb_buffer_pool_stats"
121 }
122 }
123 }
124
125 /// Concatenation of every variant's SQL text, in `ALL`-order,
126 /// separated by a single newline. The SHA-256 of this string is
127 /// pinned by `tests/live_query_allowlist_lock_mysql.rs`.
128 pub fn sql_concat_for_lock() -> String {
129 let mut s = String::new();
130 for (i, q) in Self::ALL.iter().enumerate() {
131 if i > 0 {
132 s.push('\n');
133 }
134 s.push_str(q.sql());
135 }
136 s
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn all_variants_enumerated_once() {
146 let n = AllowedMySqlQuery::ALL.len();
147 let unique: std::collections::HashSet<_> =
148 AllowedMySqlQuery::ALL.iter().copied().collect();
149 assert_eq!(n, unique.len(), "duplicate variant in AllowedMySqlQuery::ALL");
150 }
151
152 #[test]
153 fn every_variant_is_pure_select() {
154 for q in AllowedMySqlQuery::ALL {
155 let sql = q.sql();
156 let head = sql.trim_start().to_uppercase();
157 assert!(
158 head.starts_with("SELECT"),
159 "AllowedMySqlQuery::{:?} does not start with SELECT: {}",
160 q,
161 sql
162 );
163 let tokens: Vec<&str> = head
164 .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
165 .filter(|t| !t.is_empty())
166 .collect();
167 for kw in &[
168 "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER",
169 "GRANT", "REVOKE", "TRUNCATE", "LOCK", "UNLOCK", "CALL",
170 "LOAD", "HANDLER",
171 ] {
172 assert!(
173 !tokens.iter().any(|t| t == kw),
174 "AllowedMySqlQuery::{:?} contains forbidden keyword {}: {}",
175 q,
176 kw,
177 sql
178 );
179 }
180 }
181 }
182
183 #[test]
184 fn sql_concat_is_deterministic() {
185 let a = AllowedMySqlQuery::sql_concat_for_lock();
186 let b = AllowedMySqlQuery::sql_concat_for_lock();
187 assert_eq!(a, b);
188 }
189}