feat(data): complete portable SQL backend parity

Align MySQL and SQLite schemas, migrations, usage, stats, export, and backfill behavior with the shared data contracts. Extend gateway startup and maintenance support across all SQL drivers.
This commit is contained in:
elky
2026-07-25 21:28:21 +08:00
parent 764e9fd131
commit 778cfb1a5c
85 changed files with 32096 additions and 2328 deletions
+29 -1
View File
@@ -764,7 +764,8 @@ impl GatewayDataState {
}
pub(crate) fn has_usage_counter_flush_backend(&self) -> bool {
self.has_usage_writer() && self.database_driver() == Some(DatabaseDriver::Postgres)
self.has_usage_writer()
&& database_driver_supports_usage_counter_flush(self.database_driver())
}
pub(crate) fn has_usage_worker_queue(&self) -> bool {
@@ -1075,3 +1076,30 @@ impl GatewayDataState {
}
}
}
fn database_driver_supports_usage_counter_flush(driver: Option<DatabaseDriver>) -> bool {
matches!(
driver,
Some(DatabaseDriver::Postgres | DatabaseDriver::Mysql | DatabaseDriver::Sqlite)
)
}
#[cfg(test)]
mod usage_counter_flush_backend_tests {
use super::database_driver_supports_usage_counter_flush;
use aether_data::DatabaseDriver;
#[test]
fn every_sql_driver_supports_usage_counter_flush() {
assert!(database_driver_supports_usage_counter_flush(Some(
DatabaseDriver::Postgres
)));
assert!(database_driver_supports_usage_counter_flush(Some(
DatabaseDriver::Mysql
)));
assert!(database_driver_supports_usage_counter_flush(Some(
DatabaseDriver::Sqlite
)));
assert!(!database_driver_supports_usage_counter_flush(None));
}
}
+107 -25
View File
@@ -85,10 +85,17 @@ enum ExportDomainArg {
Endpoints,
Models,
GlobalModels,
AuthModules,
OAuthProviders,
UserOAuthLinks,
UserGroups,
UserGroupMembers,
ProxyNodes,
SystemConfigs,
Wallets,
Usage,
Billing,
Auxiliary,
}
impl From<ExportDomainArg> for ExportDomain {
@@ -101,10 +108,17 @@ impl From<ExportDomainArg> for ExportDomain {
ExportDomainArg::Endpoints => ExportDomain::Endpoints,
ExportDomainArg::Models => ExportDomain::Models,
ExportDomainArg::GlobalModels => ExportDomain::GlobalModels,
ExportDomainArg::AuthModules => ExportDomain::AuthModules,
ExportDomainArg::OAuthProviders => ExportDomain::OAuthProviders,
ExportDomainArg::UserOAuthLinks => ExportDomain::UserOAuthLinks,
ExportDomainArg::UserGroups => ExportDomain::UserGroups,
ExportDomainArg::UserGroupMembers => ExportDomain::UserGroupMembers,
ExportDomainArg::ProxyNodes => ExportDomain::ProxyNodes,
ExportDomainArg::SystemConfigs => ExportDomain::SystemConfigs,
ExportDomainArg::Wallets => ExportDomain::Wallets,
ExportDomainArg::Usage => ExportDomain::Usage,
ExportDomainArg::Billing => ExportDomain::Billing,
ExportDomainArg::Auxiliary => ExportDomain::Auxiliary,
}
}
}
@@ -586,14 +600,23 @@ impl GatewayDataArgs {
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let legacy_postgres_url = self
.postgres_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let generic_database_url = std::env::var("DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
match (self.effective_database_driver(), configured_url) {
(Some(DatabaseDriver::Sqlite), None) => Some(DEFAULT_SQLITE_DATABASE_URL.to_string()),
(_, Some(url)) => Some(url),
(None, None) => self.effective_postgres_url(),
(Some(DatabaseDriver::Postgres), None) => self.effective_postgres_url(),
(Some(DatabaseDriver::Mysql), None) => None,
}
resolve_database_url(
self.effective_database_driver(),
configured_url,
legacy_postgres_url,
generic_database_url,
)
}
fn effective_sql_database_config(&self) -> Option<SqlDatabaseConfig> {
@@ -649,20 +672,6 @@ impl GatewayDataArgs {
}
}
fn effective_postgres_url(&self) -> Option<String> {
self.postgres_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
std::env::var("DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}
fn effective_redis_url(&self) -> Option<String> {
self.redis_url
.as_deref()
@@ -725,6 +734,25 @@ impl GatewayDataArgs {
}
}
fn resolve_database_url(
driver: Option<DatabaseDriver>,
configured_url: Option<String>,
legacy_postgres_url: Option<String>,
generic_database_url: Option<String>,
) -> Option<String> {
if configured_url.is_some() {
return configured_url;
}
match driver {
Some(DatabaseDriver::Sqlite) => {
generic_database_url.or_else(|| Some(DEFAULT_SQLITE_DATABASE_URL.to_string()))
}
Some(DatabaseDriver::Mysql) => generic_database_url,
Some(DatabaseDriver::Postgres) | None => legacy_postgres_url.or(generic_database_url),
}
}
#[derive(ClapArgs, Debug, Clone)]
struct GatewayUsageArgs {
#[arg(
@@ -1708,7 +1736,7 @@ fn validate_deployment_topology(
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"AETHER_GATEWAY_VIDEO_TASK_STORE_PATH must be unset when AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node; use shared Postgres-backed state instead",
"AETHER_GATEWAY_VIDEO_TASK_STORE_PATH must be unset when AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node; use shared SQL-backed state instead",
));
}
@@ -1764,7 +1792,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
}
init_service_runtime(args.runtime_config()?)?;
let sql_database_config = args.data.effective_sql_database_config();
let data_postgres_url = args.data.effective_postgres_url();
let data_redis_url = args.data.effective_redis_url();
let runtime_backend =
args.effective_runtime_backend(sql_database_config.as_ref(), data_redis_url.as_deref());
@@ -1932,7 +1959,9 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.as_ref()
.map(|database| database.pool.max_connections)
.unwrap_or_default(),
data_postgres_configured = data_postgres_url.is_some(),
data_postgres_configured = sql_database_config
.as_ref()
.is_some_and(|database| database.driver == DatabaseDriver::Postgres),
runtime_redis_configured = matches!(runtime_backend, RuntimeBackendArg::Redis),
data_redis_url_supplied = data_redis_url.is_some(),
data_has_encryption_key = data_config.encryption_key().is_some(),
@@ -1989,7 +2018,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"usage persistence requires a configured Postgres data backend; set AETHER_GATEWAY_DATA_POSTGRES_URL before starting aether-gateway",
"usage persistence requires a configured shared SQL data backend; set AETHER_DATABASE_DRIVER and AETHER_DATABASE_URL before starting aether-gateway",
)
.into());
}
@@ -2751,6 +2780,42 @@ mod tests {
assert_eq!(database.pool.max_connections, 1);
}
#[test]
fn explicit_mysql_driver_accepts_generic_database_url() {
let url = super::resolve_database_url(
Some(DatabaseDriver::Mysql),
None,
Some("postgres://legacy/aether".to_string()),
Some("mysql://root:root@localhost/aether".to_string()),
);
assert_eq!(url.as_deref(), Some("mysql://root:root@localhost/aether"));
}
#[test]
fn explicit_sqlite_driver_accepts_generic_database_url() {
let url = super::resolve_database_url(
Some(DatabaseDriver::Sqlite),
None,
Some("postgres://legacy/aether".to_string()),
Some("sqlite:///opt/aether/data/aether.db".to_string()),
);
assert_eq!(url.as_deref(), Some("sqlite:///opt/aether/data/aether.db"));
}
#[test]
fn postgres_legacy_url_keeps_precedence_over_generic_database_url() {
let url = super::resolve_database_url(
Some(DatabaseDriver::Postgres),
None,
Some("postgres://legacy/aether".to_string()),
Some("postgres://generic/aether".to_string()),
);
assert_eq!(url.as_deref(), Some("postgres://legacy/aether"));
}
#[test]
fn gateway_data_pool_auto_sizes_server_databases_from_runtime_cpu() {
let mut args = test_args();
@@ -3278,6 +3343,23 @@ mod tests {
.expect("single-node sqlite memory runtime should be accepted");
}
#[test]
fn multi_node_accepts_mysql_database_backend() {
let mut args = test_args();
args.deployment_topology = DeploymentTopologyArg::MultiNode;
args.node_role = NodeRoleArg::Frontdoor;
args.video_task_store_path = None;
let database = test_database(DatabaseDriver::Mysql, 8);
super::validate_deployment_topology(
&args,
Some(&database),
Some("redis://127.0.0.1/0"),
RuntimeBackendArg::Redis,
)
.expect("multi-node mysql with shared redis should be accepted");
}
#[test]
fn multi_node_rejects_memory_runtime_backend() {
let mut args = test_args();
@@ -243,8 +243,7 @@ impl AppState {
) -> Result<(), GatewayError> {
if !self.has_auth_user_write_capability() || !self.has_auth_wallet_write_capability() {
return Err(GatewayError::Internal(
"bootstrap admin requires Postgres-backed user and wallet write capability"
.to_string(),
"bootstrap admin requires SQL-backed user and wallet write capability".to_string(),
));
}
@@ -1902,15 +1902,22 @@ fn lifecycle_backfills_are_partitioned_by_driver() {
));
for required in [
format!("use crate::driver::{driver}::{pool}"),
format!("sqlx::migrate!(\"./backfills/{driver}\")"),
"static BACKFILL_MIGRATOR".to_string(),
"ENSURE_SCHEMA_BACKFILLS_TABLE_SQL".to_string(),
"LIST_APPLIED_BACKFILLS_SQL".to_string(),
"INSERT_APPLIED_BACKFILL_SQL".to_string(),
"pub async fn run_backfills".to_string(),
"pub async fn pending_backfills".to_string(),
"ensure_schema_backfills_table".to_string(),
"validate_applied_backfills".to_string(),
] {
assert!(
source.contains(&required),
"backfill/{driver}.rs should own {required}"
);
}
for forbidden in ["PgPool", "BACKFILL_MIGRATOR", "schema_backfills"] {
for forbidden in ["PgPool", "PgConnection", "crate::driver::postgres"] {
assert!(
!source.contains(forbidden),
"backfill/{driver}.rs should not depend on PostgreSQL via {forbidden}"