1use std::{path::PathBuf, 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::{
15 invalid_config, map_operation_error, name_mapping_bypass, result_limit_exceeded,
16 transaction_begin_failed,
17 },
18 name_mapping::{
19 MappingStartupConfig, OperationSql, PhysicalOperationPlans, StaticLogicalTable, freeze,
20 },
21 row::row_payload_bytes,
22};
23
24pub const MAX_QUERY_ROWS: usize = 10_000;
25pub const MAX_INBOUND_PACKET_BYTES: u64 = 8_388_608;
32
33pub struct DatabaseStartupInjection {
39 url: String,
40 mapping_directory: PathBuf,
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum DatabaseStartupInjectionError {
45 InvalidConnectionEnvironment,
46 MissingConnectionSecret,
47 InvalidConnectionSecret,
48 InvalidMappingDirectory,
49 MappingDirectoryUnavailable,
50}
51
52impl DatabaseStartupInjectionError {
53 pub const fn code(self) -> &'static str {
54 match self {
55 Self::InvalidConnectionEnvironment => "db.startup.connection_env_invalid",
56 Self::MissingConnectionSecret => "db.startup.connection_secret_missing",
57 Self::InvalidConnectionSecret => "db.startup.connection_secret_invalid",
58 Self::InvalidMappingDirectory => "db.startup.mapping_dir_invalid",
59 Self::MappingDirectoryUnavailable => "db.startup.mapping_dir_unavailable",
60 }
61 }
62}
63
64impl DatabaseStartupInjection {
65 #[doc(hidden)]
68 pub fn load(
69 config_directory: &std::path::Path,
70 connection_environment: &str,
71 mapping_directory: &std::path::Path,
72 ) -> std::result::Result<Self, DatabaseStartupInjectionError> {
73 if !valid_environment_name(connection_environment) {
74 return Err(DatabaseStartupInjectionError::InvalidConnectionEnvironment);
75 }
76 let url = std::env::var(connection_environment).map_err(|error| match error {
77 std::env::VarError::NotPresent => {
78 DatabaseStartupInjectionError::MissingConnectionSecret
79 }
80 std::env::VarError::NotUnicode(_) => {
81 DatabaseStartupInjectionError::InvalidConnectionSecret
82 }
83 })?;
84 if url.trim().is_empty() {
85 return Err(DatabaseStartupInjectionError::InvalidConnectionSecret);
86 }
87 if mapping_directory.as_os_str().is_empty()
88 || mapping_directory.is_absolute()
89 || mapping_directory
90 .components()
91 .any(|component| !matches!(component, std::path::Component::Normal(_)))
92 {
93 return Err(DatabaseStartupInjectionError::InvalidMappingDirectory);
94 }
95 let mapping_directory = config_directory.join(mapping_directory);
96 if !mapping_directory.is_dir() {
97 return Err(DatabaseStartupInjectionError::MappingDirectoryUnavailable);
98 }
99 Ok(Self {
100 url,
101 mapping_directory,
102 })
103 }
104
105 #[doc(hidden)]
106 pub fn into_database_config(self) -> DatabaseConfig {
107 DatabaseConfig::new(self.url).name_mapping_directory(self.mapping_directory)
108 }
109}
110
111fn valid_environment_name(value: &str) -> bool {
112 let mut bytes = value.bytes();
113 let Some(first) = bytes.next() else {
114 return false;
115 };
116 (first.is_ascii_alphabetic() || first == b'_')
117 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
118}
119
120#[derive(Clone)]
122pub struct DatabaseConfig {
123 url: String,
124 max_connections: u32,
125 acquire_timeout: Duration,
126 name_mappings: Option<MappingStartupConfig>,
127}
128
129impl DatabaseConfig {
130 pub fn new(url: impl Into<String>) -> Self {
131 Self {
132 url: url.into(),
133 max_connections: 16,
134 acquire_timeout: Duration::from_secs(5),
135 name_mappings: None,
136 }
137 }
138 pub fn max_connections(mut self, value: u32) -> Self {
139 self.max_connections = value;
140 self
141 }
142 pub fn acquire_timeout(mut self, value: Duration) -> Self {
143 self.acquire_timeout = value;
144 self
145 }
146
147 pub fn name_mapping_directory(mut self, directory: impl Into<PathBuf>) -> Self {
151 self.name_mappings
152 .get_or_insert_with(|| MappingStartupConfig::new(PathBuf::new()))
153 .set_directory(directory.into());
154 self
155 }
156
157 pub fn register_logical_table<T: StaticLogicalTable>(mut self) -> Self {
159 self.name_mappings
160 .get_or_insert_with(|| MappingStartupConfig::new(PathBuf::new()))
161 .register_table::<T>();
162 self
163 }
164
165 #[doc(hidden)]
166 pub fn register_query_operation<O: crate::internal::StaticQueryOptionalOperation>(
167 mut self,
168 ) -> Self {
169 self.name_mappings
170 .get_or_insert_with(|| MappingStartupConfig::new(PathBuf::new()))
171 .register_query::<O>(O::OPERATION, O::LOGICAL_TABLE, O::LOGICAL_COLUMNS, O::SQL);
172 self
173 }
174
175 #[doc(hidden)]
176 pub fn register_write_operation<O: crate::internal::StaticWriteOperation>(mut self) -> Self {
177 self.name_mappings
178 .get_or_insert_with(|| MappingStartupConfig::new(PathBuf::new()))
179 .register_write::<O>(O::OPERATION, O::LOGICAL_TABLE, O::LOGICAL_COLUMNS, O::SQL);
180 self
181 }
182
183 pub fn validate_name_mappings(
186 &self,
187 ) -> std::result::Result<(), crate::DatabaseNameMappingError> {
188 freeze(self.name_mappings.clone()).map(|_| ())
189 }
190
191 pub(crate) fn verified_connections(mut self, value: u32) -> Self {
192 self.max_connections = value;
193 self
194 }
195
196 pub(crate) fn deployment_url(&self) -> &str {
197 &self.url
198 }
199
200 pub(crate) fn options(&self) -> Result<MySqlConnectOptions> {
201 if self.max_connections == 0 {
202 return Err(invalid_config("max connections must be greater than zero"));
203 }
204 if self.acquire_timeout.is_zero() {
205 return Err(invalid_config("acquire timeout must be greater than zero"));
206 }
207 MySqlConnectOptions::from_str(&self.url)
208 .map_err(|_| invalid_config("database URL is not a valid MySQL/MariaDB URL"))
209 }
210}
211
212#[derive(Clone)]
214pub struct Database {
215 pub(crate) pool: MySqlPool,
216 observer: Observer,
217 cleanup: Arc<CleanupCoordinator>,
218 physical_plans: Arc<PhysicalOperationPlans>,
219}
220
221impl Database {
222 pub async fn connect(config: DatabaseConfig, observer: Observer) -> Result<Self> {
224 let physical_plans = freeze(config.name_mappings.clone())
225 .map_err(|_| invalid_config("database name mapping is invalid"))?;
226 let options = config.options()?;
227 let mut preflight = MySqlConnection::connect_with(&options)
228 .await
229 .map_err(map_operation_error)?;
230 let server_packet_limit = sqlx::query_scalar::<_, u64>("SELECT @@max_allowed_packet")
231 .fetch_one(&mut preflight)
232 .await
233 .map_err(map_operation_error)?;
234 preflight.close().await.map_err(map_operation_error)?;
235 if server_packet_limit > MAX_INBOUND_PACKET_BYTES {
236 return Err(invalid_config(
237 "server max_allowed_packet exceeds the V1 inbound allocation limit",
238 ));
239 }
240 let pool = MySqlPoolOptions::new()
241 .max_connections(config.max_connections)
242 .acquire_timeout(config.acquire_timeout)
243 .idle_timeout(None)
244 .max_lifetime(None)
245 .after_connect(|connection, _metadata| {
246 Box::pin(async move {
247 let packet_limit = sqlx::query_scalar::<_, u64>("SELECT @@max_allowed_packet")
248 .fetch_one(connection)
249 .await?;
250 if packet_limit > MAX_INBOUND_PACKET_BYTES {
251 return Err(sqlx::Error::Protocol(
252 "server packet limit exceeds Saddle V1 allocation boundary".to_owned(),
253 ));
254 }
255 Ok(())
256 })
257 })
258 .connect_with(options)
259 .await
260 .map_err(map_operation_error)?;
261 let mut preopened = Vec::new();
262 preopened
263 .try_reserve_exact(config.max_connections as usize)
264 .map_err(|_| invalid_config("database connection profile is too large"))?;
265 while preopened.len() < config.max_connections as usize {
266 preopened.push(pool.acquire().await.map_err(map_operation_error)?);
267 }
268 for connection in &mut preopened {
269 connection.return_to_pool().await;
270 }
271 Ok(Self {
272 pool,
273 observer,
274 cleanup: CleanupCoordinator::start(),
275 physical_plans: Arc::new(physical_plans),
276 })
277 }
278
279 pub(crate) fn query_sql<O: crate::internal::StaticQueryOptionalOperation>(
280 &self,
281 ) -> OperationSql {
282 self.physical_plans.query::<O>()
283 }
284
285 pub(crate) fn write_sql<O: crate::internal::StaticWriteOperation>(&self) -> OperationSql {
286 self.physical_plans.write::<O>()
287 }
288
289 pub async fn query_all(
290 &self,
291 parent: &CallContext,
292 statement: Statement,
293 ) -> Result<Vec<DbRow>> {
294 if self.physical_plans.enabled() {
295 return Err(name_mapping_bypass());
296 }
297 statement.validate()?;
298 let operation = statement.operation().to_owned();
299 let call = self.observer.start_child_call(
300 parent,
301 CallKind::Database,
302 "database",
303 "database",
304 OperationId::from(operation),
305 );
306 let result = async {
307 let mut stream = statement.query().fetch(&self.pool);
308 let mut rows = Vec::new();
309 let mut result_bytes = 0_usize;
310 while let Some(row) = stream.try_next().await.map_err(map_operation_error)? {
311 if rows.len() == MAX_QUERY_ROWS {
312 return Err(result_limit_exceeded());
313 }
314 result_bytes = result_bytes.saturating_add(row_payload_bytes(&row)?);
315 if result_bytes > MAX_RESULT_BYTES {
316 return Err(result_limit_exceeded());
317 }
318 rows.push(DbRow(row));
319 }
320 Ok(rows)
321 }
322 .await;
323 finish_call(call, &result);
324 result
325 }
326
327 pub async fn query_optional(
328 &self,
329 parent: &CallContext,
330 statement: Statement,
331 ) -> Result<Option<DbRow>> {
332 if self.physical_plans.enabled() {
333 return Err(name_mapping_bypass());
334 }
335 statement.validate()?;
336 let operation = statement.operation().to_owned();
337 let call = self.observer.start_child_call(
338 parent,
339 CallKind::Database,
340 "database",
341 "database",
342 OperationId::from(operation),
343 );
344 let result = statement
345 .query()
346 .fetch_optional(&self.pool)
347 .await
348 .map_err(map_operation_error)
349 .and_then(|row| {
350 row.map(|row| {
351 row_payload_bytes(&row)?;
352 Ok(DbRow(row))
353 })
354 .transpose()
355 });
356 finish_call(call, &result);
357 result
358 }
359
360 pub async fn write(&self, parent: &CallContext, statement: Statement) -> Result<WriteResult> {
361 if self.physical_plans.enabled() {
362 return Err(name_mapping_bypass());
363 }
364 statement.validate()?;
365 let operation = statement.operation().to_owned();
366 let call = self.observer.start_child_call(
367 parent,
368 CallKind::Database,
369 "database",
370 "database",
371 OperationId::from(operation),
372 );
373 let result = statement
374 .query()
375 .execute(&self.pool)
376 .await
377 .map(|result| WriteResult::new(result.rows_affected(), result.last_insert_id()))
378 .map_err(map_operation_error);
379 finish_call(call, &result);
380 result
381 }
382
383 pub async fn transaction<T, F>(
388 &self,
389 parent: &CallContext,
390 operation: impl Into<OperationId>,
391 work: F,
392 ) -> Result<T>
393 where
394 T: Send,
395 F: for<'a> FnOnce(&'a mut Transaction) -> TransactionFuture<'a, T> + Send,
396 {
397 if self.physical_plans.enabled() {
398 return Err(name_mapping_bypass());
399 }
400 let operation = operation.into();
401 crate::statement::validate_operation(operation.as_str())?;
402 let call = self.observer.start_child_call(
403 parent,
404 CallKind::Transaction,
405 "database",
406 "database",
407 operation,
408 );
409 let cleanup = match self.cleanup.transaction_sender() {
410 Some(cleanup) => cleanup,
411 None => {
412 let error = transaction_begin_failed();
413 call.fail(&error);
414 return Err(error);
415 }
416 };
417 let begin = self.observer.start_child_call(
418 call.context(),
419 CallKind::Transaction,
420 "database",
421 "database",
422 "begin",
423 );
424 let raw = match self.pool.begin().await {
425 Ok(transaction) => {
426 begin.succeed();
427 transaction
428 }
429 Err(_) => {
430 let error = transaction_begin_failed();
431 begin.fail(&error);
432 call.fail(&error);
433 return Err(error);
434 }
435 };
436 let mut transaction = Transaction::new(raw, self.observer.clone(), call, cleanup);
437 match work(&mut transaction).await {
438 Ok(value) => transaction.commit().await.map(|()| value),
439 Err(work_error) => Err(transaction.rollback(work_error).await),
440 }
441 }
442
443 pub(crate) async fn close(&self) -> Result<()> {
444 let cleanup = self.cleanup.shutdown().await;
445 self.pool.close().await;
446 cleanup
447 }
448
449 #[cfg(test)]
450 pub(crate) fn connect_lazy(config: DatabaseConfig, observer: Observer) -> Result<Self> {
451 let physical_plans = freeze(config.name_mappings.clone())
452 .map_err(|_| invalid_config("database name mapping is invalid"))?;
453 let options = config.options()?;
454 let pool = MySqlPoolOptions::new()
455 .max_connections(config.max_connections)
456 .acquire_timeout(config.acquire_timeout)
457 .connect_lazy_with(options);
458 Ok(Self {
459 pool,
460 observer,
461 cleanup: CleanupCoordinator::start(),
462 physical_plans: Arc::new(physical_plans),
463 })
464 }
465}
466
467impl ComponentLifecycle for Database {
468 fn name(&self) -> &'static str {
469 "database"
470 }
471 fn start(&self) -> LifecycleFuture<'_> {
472 Box::pin(async { Ok(()) })
473 }
474 fn shutdown(&self) -> LifecycleFuture<'_> {
475 Box::pin(async move { self.close().await })
476 }
477}
478
479fn finish_call<T>(call: saddle_observability::ActiveCall, result: &Result<T>) {
480 match result {
481 Ok(_) => call.succeed(),
482 Err(error) => call.fail(error),
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use saddle_core::{ApplicationId, ErrorKind, ModuleId, ServiceId, SpanId, TraceId};
489 use saddle_observability::ObserverConfig;
490 use serde_json::Value;
491 use std::{
492 io,
493 sync::{Arc, Mutex},
494 };
495
496 use super::*;
497 use crate::SaddleError;
498
499 struct MappedTable;
500
501 impl crate::StaticLogicalTable for MappedTable {
502 const TABLE: &'static str = "逻辑表";
503 const COLUMNS: &'static [&'static str] = &["逻辑列"];
504 }
505
506 #[derive(Clone, Default)]
507 struct Capture(Arc<Mutex<Vec<u8>>>);
508 impl io::Write for Capture {
509 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
510 self.0.lock().unwrap().extend_from_slice(bytes);
511 Ok(bytes.len())
512 }
513 fn flush(&mut self) -> io::Result<()> {
514 Ok(())
515 }
516 }
517 fn context() -> CallContext {
518 CallContext::new(
519 ApplicationId::from("shop"),
520 ModuleId::from("orders"),
521 ServiceId::from("orders"),
522 OperationId::from("create"),
523 TraceId::from_u128(1),
524 SpanId::from_u64(2),
525 )
526 }
527
528 #[test]
529 fn configuration_rejects_invalid_bounds_without_exposing_url() {
530 let observer = Observer::with_writer(ObserverConfig::default(), io::sink()).unwrap();
531 let error = Database::connect_lazy(
532 DatabaseConfig::new("mysql://secret@localhost/db").max_connections(0),
533 observer,
534 )
535 .err()
536 .unwrap();
537 assert_eq!(error.code(), "db.invalid_config");
538 assert!(!error.to_string().contains("secret"));
539 }
540
541 #[test]
542 fn unified_startup_injection_resolves_secret_and_relative_mapping_directory() {
543 let directory =
544 std::env::temp_dir().join(format!("saddle-db-alpha10-config-{}", std::process::id()));
545 let mappings = directory.join("mappings");
546 let _ = std::fs::remove_dir_all(&directory);
547 std::fs::create_dir_all(&mappings).unwrap();
548 let executable = std::env::current_exe().unwrap();
549 for mode in ["happy", "missing", "bad-path"] {
550 let mut child = std::process::Command::new(&executable);
551 child
552 .args([
553 "--ignored",
554 "--exact",
555 "database::tests::unified_startup_injection_child",
556 ])
557 .env("SADDLE_ALPHA10_INJECTION_MODE", mode)
558 .env("SADDLE_ALPHA10_CONFIG_ROOT", &directory)
559 .env_remove("SADDLE_ALPHA10_DATABASE_TEST");
560 if mode != "missing" {
561 child.env(
562 "SADDLE_ALPHA10_DATABASE_TEST",
563 "mysql://deployment-secret@localhost/database",
564 );
565 }
566 assert!(
567 child.status().unwrap().success(),
568 "child mode {mode} failed"
569 );
570 }
571 std::fs::remove_dir_all(directory).unwrap();
572 }
573
574 #[test]
575 #[ignore = "executed in isolated child processes by the parent test"]
576 fn unified_startup_injection_child() {
577 let root = PathBuf::from(std::env::var_os("SADDLE_ALPHA10_CONFIG_ROOT").unwrap());
578 let mode = std::env::var("SADDLE_ALPHA10_INJECTION_MODE").unwrap();
579 let mapping = if mode == "bad-path" {
580 std::path::Path::new("../mappings")
581 } else {
582 std::path::Path::new("mappings")
583 };
584 let result = DatabaseStartupInjection::load(&root, "SADDLE_ALPHA10_DATABASE_TEST", mapping);
585 match mode.as_str() {
586 "happy" => {
587 let injection = result.unwrap();
588 assert_eq!(injection.mapping_directory, root.join("mappings"));
589 assert!(injection.url.contains("deployment-secret"));
590 }
591 "missing" => assert_eq!(
592 result.err(),
593 Some(DatabaseStartupInjectionError::MissingConnectionSecret)
594 ),
595 "bad-path" => assert_eq!(
596 result.err(),
597 Some(DatabaseStartupInjectionError::InvalidMappingDirectory)
598 ),
599 _ => panic!("unknown child mode"),
600 }
601 }
602
603 #[tokio::test]
604 async fn mapping_enabled_rejects_legacy_raw_statement_before_database_io() {
605 let directory =
606 std::env::temp_dir().join(format!("saddle-db-alpha7-bypass-{}", std::process::id()));
607 let _ = std::fs::remove_dir_all(&directory);
608 std::fs::create_dir(&directory).unwrap();
609 std::fs::write(
610 directory.join("table.json"),
611 r#"{"table":{"from":"逻辑表","to":"physical_table"},"columns":[{"from":"逻辑列","to":"physical_column"}]}"#,
612 )
613 .unwrap();
614 let observer = Observer::with_writer(ObserverConfig::default(), io::sink()).unwrap();
615 let database = Database::connect_lazy(
616 DatabaseConfig::new("mysql://localhost/unused")
617 .name_mapping_directory(&directory)
618 .register_logical_table::<MappedTable>(),
619 observer,
620 )
621 .unwrap();
622 let error = database
623 .write(
624 &context(),
625 Statement::new("legacy.write", "SELECT 1").unwrap(),
626 )
627 .await
628 .unwrap_err();
629 assert_eq!(error.code(), "db.name_mapping_required");
630 database.shutdown().await.unwrap();
631 std::fs::remove_dir_all(directory).unwrap();
632 }
633
634 #[tokio::test]
635 async fn closed_pool_query_has_stable_error_and_trace_record() {
636 let capture = Capture::default();
637 let observer = Observer::with_writer(ObserverConfig::default(), capture.clone()).unwrap();
638 let database = Database::connect_lazy(
639 DatabaseConfig::new("mysql://localhost/db"),
640 observer.clone(),
641 )
642 .unwrap();
643 database.close().await.unwrap();
644 let error = match database
645 .query_all(
646 &context(),
647 Statement::new("orders.list", "SELECT 1").unwrap(),
648 )
649 .await
650 {
651 Ok(_) => panic!("closed pool query unexpectedly succeeded"),
652 Err(error) => error,
653 };
654 assert_eq!(error.kind(), ErrorKind::Unavailable);
655 assert_eq!(error.code(), "db.connection_unavailable");
656 observer.flush().await.unwrap();
657 let output = String::from_utf8(capture.0.lock().unwrap().clone()).unwrap();
658 let records: Vec<Value> = output
659 .lines()
660 .map(|line| serde_json::from_str(line).unwrap())
661 .collect();
662 assert_eq!(records[0]["call_kind"], "database");
663 assert_eq!(records[1]["error_code"], "db.connection_unavailable");
664 assert_eq!(records[0]["trace_id"], context().trace_id().to_string());
665 assert!(!output.contains("SELECT 1"));
666 }
667
668 #[tokio::test]
669 async fn transaction_begin_failure_records_phase_and_stable_error() {
670 let capture = Capture::default();
671 let observer = Observer::with_writer(ObserverConfig::default(), capture.clone()).unwrap();
672 let database = Database::connect_lazy(
673 DatabaseConfig::new("mysql://localhost/db"),
674 observer.clone(),
675 )
676 .unwrap();
677 database.pool.close().await;
678 let error = database
679 .transaction(&context(), "orders.create", |_transaction| {
680 Box::pin(async { Ok::<_, SaddleError>(()) })
681 })
682 .await
683 .unwrap_err();
684 assert_eq!(error.code(), "db.transaction_begin_failed");
685 observer.flush().await.unwrap();
686 let output = String::from_utf8(capture.0.lock().unwrap().clone()).unwrap();
687 let records: Vec<Value> = output
688 .lines()
689 .map(|line| serde_json::from_str(line).unwrap())
690 .collect();
691 assert_eq!(records.len(), 4);
692 assert_eq!(records[0]["operation"], "orders.create");
693 assert_eq!(records[1]["operation"], "begin");
694 assert_eq!(records[2]["error_code"], "db.transaction_begin_failed");
695 assert_eq!(records[3]["error_code"], "db.transaction_begin_failed");
696 assert!(
697 records
698 .iter()
699 .all(|record| record["trace_id"] == context().trace_id().to_string())
700 );
701 }
702
703 #[allow(dead_code)]
704 async fn transaction_usage_compiles(database: &Database, context: &CallContext) -> Result<()> {
705 database
706 .transaction(context, "orders.create", |transaction| {
707 Box::pin(async move {
708 transaction
709 .write(
710 Statement::new("orders.insert", "INSERT INTO orders(id) VALUES (?)")?
711 .bind(1_u64)?,
712 )
713 .await?;
714 transaction
715 .query_optional(
716 Statement::new("orders.find", "SELECT id FROM orders WHERE id = ?")?
717 .bind(1_u64)?,
718 )
719 .await?;
720 Ok(())
721 })
722 })
723 .await
724 }
725}