1use std::{str::FromStr, sync::Arc, time::Duration};
2
3use futures_util::TryStreamExt;
4use saddle_core::{ComponentLifecycle, LifecycleFuture, OperationId, Result};
5use saddle_observability::{CallKind, Observer};
6use sqlx::{
7 Connection, MySqlPool,
8 mysql::{MySqlConnectOptions, MySqlConnection, MySqlPoolOptions},
9};
10
11use crate::{
12 CallContext, DbRow, MAX_RESULT_BYTES, Statement, Transaction, TransactionFuture, WriteResult,
13 cleanup::CleanupCoordinator,
14 error::{invalid_config, map_operation_error, result_limit_exceeded, transaction_begin_failed},
15 row::row_payload_bytes,
16};
17
18pub const MAX_QUERY_ROWS: usize = 10_000;
19pub const MAX_INBOUND_PACKET_BYTES: u64 = 8_388_608;
26
27#[derive(Clone)]
29pub struct DatabaseConfig {
30 url: String,
31 max_connections: u32,
32 acquire_timeout: Duration,
33}
34
35impl DatabaseConfig {
36 pub fn new(url: impl Into<String>) -> Self {
37 Self {
38 url: url.into(),
39 max_connections: 16,
40 acquire_timeout: Duration::from_secs(5),
41 }
42 }
43 pub fn max_connections(mut self, value: u32) -> Self {
44 self.max_connections = value;
45 self
46 }
47 pub fn acquire_timeout(mut self, value: Duration) -> Self {
48 self.acquire_timeout = value;
49 self
50 }
51
52 pub(crate) fn verified_connections(mut self, value: u32) -> Self {
53 self.max_connections = value;
54 self
55 }
56
57 pub(crate) fn deployment_url(&self) -> &str {
58 &self.url
59 }
60
61 pub(crate) fn options(&self) -> Result<MySqlConnectOptions> {
62 if self.max_connections == 0 {
63 return Err(invalid_config("max connections must be greater than zero"));
64 }
65 if self.acquire_timeout.is_zero() {
66 return Err(invalid_config("acquire timeout must be greater than zero"));
67 }
68 MySqlConnectOptions::from_str(&self.url)
69 .map_err(|_| invalid_config("database URL is not a valid MySQL/MariaDB URL"))
70 }
71}
72
73#[derive(Clone)]
75pub struct Database {
76 pub(crate) pool: MySqlPool,
77 observer: Observer,
78 cleanup: Arc<CleanupCoordinator>,
79}
80
81impl Database {
82 pub async fn connect(config: DatabaseConfig, observer: Observer) -> Result<Self> {
84 let options = config.options()?;
85 let mut preflight = MySqlConnection::connect_with(&options)
86 .await
87 .map_err(map_operation_error)?;
88 let server_packet_limit = sqlx::query_scalar::<_, u64>("SELECT @@max_allowed_packet")
89 .fetch_one(&mut preflight)
90 .await
91 .map_err(map_operation_error)?;
92 preflight.close().await.map_err(map_operation_error)?;
93 if server_packet_limit > MAX_INBOUND_PACKET_BYTES {
94 return Err(invalid_config(
95 "server max_allowed_packet exceeds the V1 inbound allocation limit",
96 ));
97 }
98 let pool = MySqlPoolOptions::new()
99 .max_connections(config.max_connections)
100 .acquire_timeout(config.acquire_timeout)
101 .idle_timeout(None)
102 .max_lifetime(None)
103 .after_connect(|connection, _metadata| {
104 Box::pin(async move {
105 let packet_limit = sqlx::query_scalar::<_, u64>("SELECT @@max_allowed_packet")
106 .fetch_one(connection)
107 .await?;
108 if packet_limit > MAX_INBOUND_PACKET_BYTES {
109 return Err(sqlx::Error::Protocol(
110 "server packet limit exceeds Saddle V1 allocation boundary".to_owned(),
111 ));
112 }
113 Ok(())
114 })
115 })
116 .connect_with(options)
117 .await
118 .map_err(map_operation_error)?;
119 let mut preopened = Vec::new();
120 preopened
121 .try_reserve_exact(config.max_connections as usize)
122 .map_err(|_| invalid_config("database connection profile is too large"))?;
123 while preopened.len() < config.max_connections as usize {
124 preopened.push(pool.acquire().await.map_err(map_operation_error)?);
125 }
126 for connection in &mut preopened {
127 connection.return_to_pool().await;
128 }
129 Ok(Self {
130 pool,
131 observer,
132 cleanup: CleanupCoordinator::start(),
133 })
134 }
135
136 pub async fn query_all(
137 &self,
138 parent: &CallContext,
139 statement: Statement,
140 ) -> Result<Vec<DbRow>> {
141 statement.validate()?;
142 let operation = statement.operation().to_owned();
143 let call = self.observer.start_child_call(
144 parent,
145 CallKind::Database,
146 "database",
147 "database",
148 OperationId::from(operation),
149 );
150 let result = async {
151 let mut stream = statement.query().fetch(&self.pool);
152 let mut rows = Vec::new();
153 let mut result_bytes = 0_usize;
154 while let Some(row) = stream.try_next().await.map_err(map_operation_error)? {
155 if rows.len() == MAX_QUERY_ROWS {
156 return Err(result_limit_exceeded());
157 }
158 result_bytes = result_bytes.saturating_add(row_payload_bytes(&row)?);
159 if result_bytes > MAX_RESULT_BYTES {
160 return Err(result_limit_exceeded());
161 }
162 rows.push(DbRow(row));
163 }
164 Ok(rows)
165 }
166 .await;
167 finish_call(call, &result);
168 result
169 }
170
171 pub async fn query_optional(
172 &self,
173 parent: &CallContext,
174 statement: Statement,
175 ) -> Result<Option<DbRow>> {
176 statement.validate()?;
177 let operation = statement.operation().to_owned();
178 let call = self.observer.start_child_call(
179 parent,
180 CallKind::Database,
181 "database",
182 "database",
183 OperationId::from(operation),
184 );
185 let result = statement
186 .query()
187 .fetch_optional(&self.pool)
188 .await
189 .map_err(map_operation_error)
190 .and_then(|row| {
191 row.map(|row| {
192 row_payload_bytes(&row)?;
193 Ok(DbRow(row))
194 })
195 .transpose()
196 });
197 finish_call(call, &result);
198 result
199 }
200
201 pub async fn write(&self, parent: &CallContext, statement: Statement) -> Result<WriteResult> {
202 statement.validate()?;
203 let operation = statement.operation().to_owned();
204 let call = self.observer.start_child_call(
205 parent,
206 CallKind::Database,
207 "database",
208 "database",
209 OperationId::from(operation),
210 );
211 let result = statement
212 .query()
213 .execute(&self.pool)
214 .await
215 .map(|result| WriteResult::new(result.rows_affected(), result.last_insert_id()))
216 .map_err(map_operation_error);
217 finish_call(call, &result);
218 result
219 }
220
221 pub async fn transaction<T, F>(
226 &self,
227 parent: &CallContext,
228 operation: impl Into<OperationId>,
229 work: F,
230 ) -> Result<T>
231 where
232 T: Send,
233 F: for<'a> FnOnce(&'a mut Transaction) -> TransactionFuture<'a, T> + Send,
234 {
235 let operation = operation.into();
236 crate::statement::validate_operation(operation.as_str())?;
237 let call = self.observer.start_child_call(
238 parent,
239 CallKind::Transaction,
240 "database",
241 "database",
242 operation,
243 );
244 let cleanup = match self.cleanup.transaction_sender() {
245 Some(cleanup) => cleanup,
246 None => {
247 let error = transaction_begin_failed();
248 call.fail(&error);
249 return Err(error);
250 }
251 };
252 let begin = self.observer.start_child_call(
253 call.context(),
254 CallKind::Transaction,
255 "database",
256 "database",
257 "begin",
258 );
259 let raw = match self.pool.begin().await {
260 Ok(transaction) => {
261 begin.succeed();
262 transaction
263 }
264 Err(_) => {
265 let error = transaction_begin_failed();
266 begin.fail(&error);
267 call.fail(&error);
268 return Err(error);
269 }
270 };
271 let mut transaction = Transaction::new(raw, self.observer.clone(), call, cleanup);
272 match work(&mut transaction).await {
273 Ok(value) => transaction.commit().await.map(|()| value),
274 Err(work_error) => Err(transaction.rollback(work_error).await),
275 }
276 }
277
278 pub(crate) async fn close(&self) -> Result<()> {
279 let cleanup = self.cleanup.shutdown().await;
280 self.pool.close().await;
281 cleanup
282 }
283
284 #[cfg(test)]
285 pub(crate) fn connect_lazy(config: DatabaseConfig, observer: Observer) -> Result<Self> {
286 let options = config.options()?;
287 let pool = MySqlPoolOptions::new()
288 .max_connections(config.max_connections)
289 .acquire_timeout(config.acquire_timeout)
290 .connect_lazy_with(options);
291 Ok(Self {
292 pool,
293 observer,
294 cleanup: CleanupCoordinator::start(),
295 })
296 }
297}
298
299impl ComponentLifecycle for Database {
300 fn name(&self) -> &'static str {
301 "database"
302 }
303 fn start(&self) -> LifecycleFuture<'_> {
304 Box::pin(async { Ok(()) })
305 }
306 fn shutdown(&self) -> LifecycleFuture<'_> {
307 Box::pin(async move { self.close().await })
308 }
309}
310
311fn finish_call<T>(call: saddle_observability::ActiveCall, result: &Result<T>) {
312 match result {
313 Ok(_) => call.succeed(),
314 Err(error) => call.fail(error),
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use saddle_core::{ApplicationId, ErrorKind, ModuleId, ServiceId, SpanId, TraceId};
321 use saddle_observability::ObserverConfig;
322 use serde_json::Value;
323 use std::{
324 io,
325 sync::{Arc, Mutex},
326 };
327
328 use super::*;
329 use crate::SaddleError;
330
331 #[derive(Clone, Default)]
332 struct Capture(Arc<Mutex<Vec<u8>>>);
333 impl io::Write for Capture {
334 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
335 self.0.lock().unwrap().extend_from_slice(bytes);
336 Ok(bytes.len())
337 }
338 fn flush(&mut self) -> io::Result<()> {
339 Ok(())
340 }
341 }
342 fn context() -> CallContext {
343 CallContext::new(
344 ApplicationId::from("shop"),
345 ModuleId::from("orders"),
346 ServiceId::from("orders"),
347 OperationId::from("create"),
348 TraceId::from_u128(1),
349 SpanId::from_u64(2),
350 )
351 }
352
353 #[test]
354 fn configuration_rejects_invalid_bounds_without_exposing_url() {
355 let observer = Observer::with_writer(ObserverConfig::default(), io::sink()).unwrap();
356 let error = Database::connect_lazy(
357 DatabaseConfig::new("mysql://secret@localhost/db").max_connections(0),
358 observer,
359 )
360 .err()
361 .unwrap();
362 assert_eq!(error.code(), "db.invalid_config");
363 assert!(!error.to_string().contains("secret"));
364 }
365
366 #[tokio::test]
367 async fn closed_pool_query_has_stable_error_and_trace_record() {
368 let capture = Capture::default();
369 let observer = Observer::with_writer(ObserverConfig::default(), capture.clone()).unwrap();
370 let database = Database::connect_lazy(
371 DatabaseConfig::new("mysql://localhost/db"),
372 observer.clone(),
373 )
374 .unwrap();
375 database.close().await.unwrap();
376 let error = match database
377 .query_all(
378 &context(),
379 Statement::new("orders.list", "SELECT 1").unwrap(),
380 )
381 .await
382 {
383 Ok(_) => panic!("closed pool query unexpectedly succeeded"),
384 Err(error) => error,
385 };
386 assert_eq!(error.kind(), ErrorKind::Unavailable);
387 assert_eq!(error.code(), "db.connection_unavailable");
388 observer.flush().await.unwrap();
389 let output = String::from_utf8(capture.0.lock().unwrap().clone()).unwrap();
390 let records: Vec<Value> = output
391 .lines()
392 .map(|line| serde_json::from_str(line).unwrap())
393 .collect();
394 assert_eq!(records[0]["call_kind"], "database");
395 assert_eq!(records[1]["error_code"], "db.connection_unavailable");
396 assert_eq!(records[0]["trace_id"], context().trace_id().to_string());
397 assert!(!output.contains("SELECT 1"));
398 }
399
400 #[tokio::test]
401 async fn transaction_begin_failure_records_phase_and_stable_error() {
402 let capture = Capture::default();
403 let observer = Observer::with_writer(ObserverConfig::default(), capture.clone()).unwrap();
404 let database = Database::connect_lazy(
405 DatabaseConfig::new("mysql://localhost/db"),
406 observer.clone(),
407 )
408 .unwrap();
409 database.pool.close().await;
410 let error = database
411 .transaction(&context(), "orders.create", |_transaction| {
412 Box::pin(async { Ok::<_, SaddleError>(()) })
413 })
414 .await
415 .unwrap_err();
416 assert_eq!(error.code(), "db.transaction_begin_failed");
417 observer.flush().await.unwrap();
418 let output = String::from_utf8(capture.0.lock().unwrap().clone()).unwrap();
419 let records: Vec<Value> = output
420 .lines()
421 .map(|line| serde_json::from_str(line).unwrap())
422 .collect();
423 assert_eq!(records.len(), 4);
424 assert_eq!(records[0]["operation"], "orders.create");
425 assert_eq!(records[1]["operation"], "begin");
426 assert_eq!(records[2]["error_code"], "db.transaction_begin_failed");
427 assert_eq!(records[3]["error_code"], "db.transaction_begin_failed");
428 assert!(
429 records
430 .iter()
431 .all(|record| record["trace_id"] == context().trace_id().to_string())
432 );
433 }
434
435 #[allow(dead_code)]
436 async fn transaction_usage_compiles(database: &Database, context: &CallContext) -> Result<()> {
437 database
438 .transaction(context, "orders.create", |transaction| {
439 Box::pin(async move {
440 transaction
441 .write(
442 Statement::new("orders.insert", "INSERT INTO orders(id) VALUES (?)")?
443 .bind(1_u64)?,
444 )
445 .await?;
446 transaction
447 .query_optional(
448 Statement::new("orders.find", "SELECT id FROM orders WHERE id = ?")?
449 .bind(1_u64)?,
450 )
451 .await?;
452 Ok(())
453 })
454 })
455 .await
456 }
457}