#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] #[global_allocator] static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; use std::path::PathBuf; use std::sync::Arc; use axum::{body::Body, extract::Request}; use clap::{Args as ClapArgs, Parser, Subcommand, ValueEnum}; use hyper::body::Incoming; use hyper_util::{ rt::{TokioExecutor, TokioIo}, server::conn::auto::Builder as HyperServerBuilder, service::TowerToHyperService, }; use tower::{Service as _, ServiceExt as _}; use tracing::{debug, info, warn}; use aether_crypto::warm_python_fernet_secret; use aether_data::lifecycle::export::{ copy_database_records, export_database_jsonl, import_database_jsonl, DataCopyOptions, ExportDomain, }; use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig, DEFAULT_SQLITE_DATABASE_URL}; use aether_gateway::{ attach_static_frontend, build_router_with_state, prewarm_direct_h2c_sender_cache_from_env_for_startup, set_gateway_frontdoor_app_port, AppState, FrontdoorCorsConfig, FrontdoorUserRpmConfig, GatewayDataConfig, UsageRuntimeConfig, VideoTaskTruthSourceMode, }; use aether_runtime::{ init_service_runtime, FileLoggingConfig, LogDestination, LogFormat, LogRotation, ServiceRuntimeConfig, }; use aether_runtime_state::{ RedisClientConfig, RuntimeSemaphoreConfig, RuntimeState, RuntimeStateBackendMode, RuntimeStateConfig, }; #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum VideoTaskTruthSourceArg { PythonSyncReport, RustAuthoritative, } impl From for VideoTaskTruthSourceMode { fn from(value: VideoTaskTruthSourceArg) -> Self { match value { VideoTaskTruthSourceArg::PythonSyncReport => VideoTaskTruthSourceMode::PythonSyncReport, VideoTaskTruthSourceArg::RustAuthoritative => { VideoTaskTruthSourceMode::RustAuthoritative } } } } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum DeploymentTopologyArg { SingleNode, MultiNode, } impl DeploymentTopologyArg { const fn as_str(self) -> &'static str { match self { Self::SingleNode => "single-node", Self::MultiNode => "multi-node", } } } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum DatabaseDriverArg { Sqlite, Mysql, Postgres, } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum ExportDomainArg { Users, ApiKeys, Providers, ProviderKeys, Endpoints, Models, GlobalModels, AuthModules, OAuthProviders, UserOAuthLinks, UserGroups, UserGroupMembers, ProxyNodes, SystemConfigs, Wallets, Usage, Billing, Auxiliary, } impl From for ExportDomain { fn from(value: ExportDomainArg) -> Self { match value { ExportDomainArg::Users => ExportDomain::Users, ExportDomainArg::ApiKeys => ExportDomain::ApiKeys, ExportDomainArg::Providers => ExportDomain::Providers, ExportDomainArg::ProviderKeys => ExportDomain::ProviderKeys, 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, } } } impl From for DatabaseDriver { fn from(value: DatabaseDriverArg) -> Self { match value { DatabaseDriverArg::Sqlite => DatabaseDriver::Sqlite, DatabaseDriverArg::Mysql => DatabaseDriver::Mysql, DatabaseDriverArg::Postgres => DatabaseDriver::Postgres, } } } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum NodeRoleArg { All, Frontdoor, Background, } impl NodeRoleArg { const fn as_str(self) -> &'static str { match self { Self::All => "all", Self::Frontdoor => "frontdoor", Self::Background => "background", } } const fn spawns_background_tasks(self) -> bool { matches!(self, Self::All | Self::Background) } const fn isolates_background_database(self) -> bool { matches!(self, Self::All) } } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum RuntimeBackendArg { Auto, Redis, Memory, } impl RuntimeBackendArg { const fn as_str(self) -> &'static str { match self { Self::Auto => "auto", Self::Redis => "redis", Self::Memory => "memory", } } const fn to_runtime_state_backend(self) -> RuntimeStateBackendMode { match self { Self::Auto => RuntimeStateBackendMode::Auto, Self::Redis => RuntimeStateBackendMode::Redis, Self::Memory => RuntimeStateBackendMode::Memory, } } } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum GatewayLogFormatArg { Pretty, Json, } impl From for LogFormat { fn from(value: GatewayLogFormatArg) -> Self { match value { GatewayLogFormatArg::Pretty => LogFormat::Pretty, GatewayLogFormatArg::Json => LogFormat::Json, } } } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum GatewayLogDestinationArg { Stdout, File, Both, } impl GatewayLogDestinationArg { const fn as_str(self) -> &'static str { match self { Self::Stdout => "stdout", Self::File => "file", Self::Both => "both", } } } impl From for LogDestination { fn from(value: GatewayLogDestinationArg) -> Self { match value { GatewayLogDestinationArg::Stdout => LogDestination::Stdout, GatewayLogDestinationArg::File => LogDestination::File, GatewayLogDestinationArg::Both => LogDestination::Both, } } } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum GatewayLogRotationArg { Hourly, Daily, } impl GatewayLogRotationArg { const fn as_str(self) -> &'static str { match self { Self::Hourly => "hourly", Self::Daily => "daily", } } } impl From for LogRotation { fn from(value: GatewayLogRotationArg) -> Self { match value { GatewayLogRotationArg::Hourly => LogRotation::Hourly, GatewayLogRotationArg::Daily => LogRotation::Daily, } } } const GATEWAY_TOKIO_WORKER_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; const DEFAULT_SQL_POOL_ACQUIRE_TIMEOUT_MS: u64 = 10_000; const DEFAULT_SQL_POOL_IDLE_TIMEOUT_MS: u64 = 30_000; const DEFAULT_SQL_POOL_MAX_LIFETIME_MS: u64 = 30 * 60_000; const DEFAULT_SQL_POOL_STATEMENT_CACHE_CAPACITY: usize = 100; const DEFAULT_SQLITE_POOL_MAX_CONNECTIONS: u32 = 1; // Per-process default for server SQL backends. Keep this below common // database server max_connections defaults; operators can override with // AETHER_GATEWAY_DATA_POSTGRES_{MIN,MAX}_CONNECTIONS after sizing the DB. const AUTO_SERVER_SQL_POOL_CONNECTIONS_PER_CPU: u32 = 4; const AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_FLOOR: u32 = 4; const AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_CAP: u32 = 16; const AUTO_SERVER_SQL_POOL_MAX_CONNECTIONS_FLOOR: u32 = 32; const AUTO_SERVER_SQL_POOL_MAX_CONNECTIONS_CAP: u32 = 100; const DEFAULT_USAGE_QUEUE_WORKERS_CAP: usize = 8; const AUTO_USAGE_QUEUE_WORKERS_MIN: usize = 2; const AUTO_USAGE_QUEUE_WORKERS_REQUESTS_PER_WORKER: usize = 128; const AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_ALL: usize = 4; const AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_BACKGROUND: usize = 2; const AUTO_USAGE_WORKER_RECORD_DB_SHARE_ALL: usize = 8; const AUTO_USAGE_WORKER_RECORD_DB_SHARE_BACKGROUND: usize = 4; const MAX_USAGE_QUEUE_WORKERS: usize = 64; const DEFAULT_GATEWAY_LISTEN_BACKLOG: i32 = 65_535; const MIN_GATEWAY_LISTEN_BACKLOG: i32 = 128; const MAX_GATEWAY_LISTEN_BACKLOG: i32 = 65_535; const DEFAULT_GATEWAY_LISTENER_SHARDS: usize = 0; const MAX_GATEWAY_LISTENER_SHARDS: usize = 64; const DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS: u32 = 16_384; const MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS: u32 = 200; const MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS: u32 = 1_000_000; const AUTO_GATEWAY_REQUESTS_PER_CPU: usize = 1_024; const MIN_AUTO_GATEWAY_REQUEST_CONCURRENCY: usize = 512; const MAX_AUTO_GATEWAY_REQUEST_CONCURRENCY: usize = 65_536; const AUTO_GATEWAY_REQUEST_FD_DIVISOR: usize = 2; const AUTO_GATEWAY_REQUEST_FD_RESERVE: usize = 256; fn env_var_trimmed(name: &str) -> Option { std::env::var(name) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn available_parallelism_u32() -> u32 { u32::try_from(available_parallelism_usize()) .unwrap_or(u32::MAX) .max(1) } fn available_parallelism_usize() -> usize { std::thread::available_parallelism() .map(|value| value.get()) .unwrap_or(AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_FLOOR as usize) .max(1) } #[cfg(test)] fn automatic_gateway_request_concurrency_for_parallelism(parallelism: usize) -> usize { automatic_gateway_request_concurrency_for_capacity(parallelism, None) } fn automatic_gateway_request_concurrency_for_capacity( parallelism: usize, fd_soft_limit: Option, ) -> usize { let cpu_limit = parallelism .max(1) .saturating_mul(AUTO_GATEWAY_REQUESTS_PER_CPU) .clamp( MIN_AUTO_GATEWAY_REQUEST_CONCURRENCY, MAX_AUTO_GATEWAY_REQUEST_CONCURRENCY, ); let fd_limit = fd_soft_limit .map(|limit| { limit .saturating_sub(AUTO_GATEWAY_REQUEST_FD_RESERVE) .checked_div(AUTO_GATEWAY_REQUEST_FD_DIVISOR) .unwrap_or(1) .max(1) }) .unwrap_or(MAX_AUTO_GATEWAY_REQUEST_CONCURRENCY); cpu_limit.min(fd_limit).max(1) } fn automatic_gateway_request_concurrency() -> usize { automatic_gateway_request_concurrency_for_capacity( available_parallelism_usize(), soft_fd_limit(), ) } fn soft_fd_limit() -> Option { #[cfg(unix)] { let mut limit = libc::rlimit { rlim_cur: 0, rlim_max: 0, }; let result = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) }; if result == 0 { return usize::try_from(limit.rlim_cur).ok(); } } None } fn usage_queue_request_concurrency_hint( max_in_flight_requests: Option, distributed_request_limit: Option, ) -> Option { match ( max_in_flight_requests.filter(|limit| *limit > 0), distributed_request_limit.filter(|limit| *limit > 0), ) { (Some(local), Some(distributed)) => Some(local.min(distributed)), (Some(local), None) => Some(local), (None, Some(distributed)) => Some(distributed), (None, None) => None, } } fn usage_queue_workers_for_request_concurrency(request_concurrency: usize) -> usize { let workers = request_concurrency .saturating_add(AUTO_USAGE_QUEUE_WORKERS_REQUESTS_PER_WORKER - 1) / AUTO_USAGE_QUEUE_WORKERS_REQUESTS_PER_WORKER; workers.clamp(AUTO_USAGE_QUEUE_WORKERS_MIN, MAX_USAGE_QUEUE_WORKERS) } fn usage_database_config_for_role<'a>( node_role: NodeRoleArg, database: Option<&'a SqlDatabaseConfig>, isolated_background_database: Option<&'a SqlDatabaseConfig>, ) -> Option<&'a SqlDatabaseConfig> { if node_role.isolates_background_database() { isolated_background_database.or(database) } else { database } } fn usage_queue_worker_database_cap( node_role: NodeRoleArg, database: Option<&SqlDatabaseConfig>, database_is_isolated: bool, ) -> usize { let Some(database) = database else { return MAX_USAGE_QUEUE_WORKERS; }; if database.driver == DatabaseDriver::Sqlite { return 1; } let max_connections = database.pool.max_connections.max(1) as usize; // An isolated pool is already a dedicated background budget. Applying the shared-pool // divisor a second time would underutilize that pool. if database_is_isolated { return max_connections .saturating_sub(1) .max(1) .clamp(1, MAX_USAGE_QUEUE_WORKERS); } let divisor = if matches!(node_role, NodeRoleArg::Background) { AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_BACKGROUND } else { AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_ALL }; max_connections .saturating_add(divisor - 1) .checked_div(divisor) .unwrap_or(1) .clamp(1, MAX_USAGE_QUEUE_WORKERS) } fn usage_worker_record_concurrency_database_cap( node_role: NodeRoleArg, database: Option<&SqlDatabaseConfig>, database_is_isolated: bool, ) -> Option { let database = database?; if database.driver == DatabaseDriver::Sqlite { return Some(1); } let max_connections = database.pool.max_connections.max(1) as usize; // The isolated background pool has already been carved out of the foreground pool. Keep one // connection available for maintenance/health work and use the rest for usage persistence. if database_is_isolated { return Some( max_connections .saturating_sub(1) .max(1) .clamp(1, MAX_USAGE_QUEUE_WORKERS), ); } let divisor = if matches!(node_role, NodeRoleArg::Background) { AUTO_USAGE_WORKER_RECORD_DB_SHARE_BACKGROUND } else { AUTO_USAGE_WORKER_RECORD_DB_SHARE_ALL }; Some( max_connections .checked_div(divisor.max(1)) .unwrap_or(1) .clamp(1, MAX_USAGE_QUEUE_WORKERS), ) } fn automatic_usage_queue_workers_for_parallelism( parallelism: usize, node_role: NodeRoleArg, max_in_flight_requests: Option, distributed_request_limit: Option, database: Option<&SqlDatabaseConfig>, database_is_isolated: bool, ) -> usize { let cpu_default = parallelism.max(1).clamp( AUTO_USAGE_QUEUE_WORKERS_MIN, DEFAULT_USAGE_QUEUE_WORKERS_CAP, ); let requested = usage_queue_request_concurrency_hint(max_in_flight_requests, distributed_request_limit) .map(usage_queue_workers_for_request_concurrency) .unwrap_or(cpu_default); requested .min(usage_queue_worker_database_cap( node_role, database, database_is_isolated, )) .clamp(1, MAX_USAGE_QUEUE_WORKERS) } fn automatic_usage_queue_workers( node_role: NodeRoleArg, max_in_flight_requests: Option, distributed_request_limit: Option, database: Option<&SqlDatabaseConfig>, database_is_isolated: bool, ) -> usize { automatic_usage_queue_workers_for_parallelism( available_parallelism_usize(), node_role, max_in_flight_requests, distributed_request_limit, database, database_is_isolated, ) } fn automatic_sql_pool_config(driver: DatabaseDriver) -> SqlPoolConfig { automatic_sql_pool_config_for_parallelism(driver, available_parallelism_u32()) } fn automatic_sql_pool_config_for_parallelism( driver: DatabaseDriver, parallelism: u32, ) -> SqlPoolConfig { let (min_connections, max_connections) = match driver { DatabaseDriver::Sqlite => (1, DEFAULT_SQLITE_POOL_MAX_CONNECTIONS), DatabaseDriver::Mysql | DatabaseDriver::Postgres => { let cpu_count = parallelism.max(1); let max_connections = cpu_count .saturating_mul(AUTO_SERVER_SQL_POOL_CONNECTIONS_PER_CPU) .clamp( AUTO_SERVER_SQL_POOL_MAX_CONNECTIONS_FLOOR, AUTO_SERVER_SQL_POOL_MAX_CONNECTIONS_CAP, ); let min_connections = cpu_count .clamp( AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_FLOOR, AUTO_SERVER_SQL_POOL_MIN_CONNECTIONS_CAP, ) .min(max_connections); (min_connections, max_connections) } }; SqlPoolConfig { min_connections, max_connections, acquire_timeout_ms: DEFAULT_SQL_POOL_ACQUIRE_TIMEOUT_MS, idle_timeout_ms: DEFAULT_SQL_POOL_IDLE_TIMEOUT_MS, max_lifetime_ms: DEFAULT_SQL_POOL_MAX_LIFETIME_MS, statement_cache_capacity: DEFAULT_SQL_POOL_STATEMENT_CACHE_CAPACITY, require_ssl: false, } } #[derive(ClapArgs, Debug, Clone)] struct GatewayDataArgs { #[arg(long, env = "AETHER_DATABASE_DRIVER")] database_driver: Option, #[arg(long, env = "AETHER_DATABASE_URL")] database_url: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_URL")] postgres_url: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_ENCRYPTION_KEY")] encryption_key: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_REDIS_URL")] redis_url: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_REDIS_KEY_PREFIX")] redis_key_prefix: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS")] postgres_min_connections: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS")] postgres_max_connections: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_ACQUIRE_TIMEOUT_MS")] postgres_acquire_timeout_ms: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_IDLE_TIMEOUT_MS")] postgres_idle_timeout_ms: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_MAX_LIFETIME_MS")] postgres_max_lifetime_ms: Option, #[arg(long, env = "AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_CACHE_CAPACITY")] postgres_statement_cache_capacity: Option, #[arg( long, env = "AETHER_GATEWAY_DATA_POSTGRES_REQUIRE_SSL", default_value_t = false )] postgres_require_ssl: bool, } impl GatewayDataArgs { fn effective_database_driver(&self) -> Option { self.database_driver.map(Into::into).or_else(|| { self.database_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .and_then(DatabaseDriver::from_database_url) }) } fn effective_database_url(&self) -> Option { let configured_url = self .database_url .as_deref() .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()); resolve_database_url( self.effective_database_driver(), configured_url, legacy_postgres_url, generic_database_url, ) } fn effective_sql_database_config(&self) -> Option { let url = self.effective_database_url()?; let driver = self .effective_database_driver() .or_else(|| DatabaseDriver::from_database_url(&url)) .unwrap_or(DatabaseDriver::Postgres); Some(SqlDatabaseConfig { driver, url, pool: self.effective_sql_pool_config(driver), }) } fn effective_sql_pool_config(&self, driver: DatabaseDriver) -> SqlPoolConfig { let auto = automatic_sql_pool_config(driver); let mut min_connections = self .postgres_min_connections .unwrap_or(auto.min_connections); let mut max_connections = self .postgres_max_connections .unwrap_or(auto.max_connections) .max(1); match (self.postgres_min_connections, self.postgres_max_connections) { (None, Some(_)) if min_connections > max_connections => { min_connections = max_connections; } (Some(_), None) if max_connections < min_connections => { max_connections = min_connections.max(1); } _ => {} } SqlPoolConfig { min_connections, max_connections, acquire_timeout_ms: self .postgres_acquire_timeout_ms .unwrap_or(auto.acquire_timeout_ms), idle_timeout_ms: self .postgres_idle_timeout_ms .unwrap_or(auto.idle_timeout_ms), max_lifetime_ms: self .postgres_max_lifetime_ms .unwrap_or(auto.max_lifetime_ms), statement_cache_capacity: self .postgres_statement_cache_capacity .unwrap_or(auto.statement_cache_capacity), require_ssl: driver != DatabaseDriver::Sqlite && self.postgres_require_ssl, } } fn effective_redis_url(&self) -> Option { self.redis_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| { std::env::var("REDIS_URL") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) }) } fn effective_encryption_key(&self) -> Option { self.encryption_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| { std::env::var("ENCRYPTION_KEY") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) }) } fn configured_encryption_key_mismatch(&self) -> bool { let gateway_value = std::env::var("AETHER_GATEWAY_DATA_ENCRYPTION_KEY") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let default_value = std::env::var("ENCRYPTION_KEY") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); matches!( (gateway_value, default_value), (Some(gateway_value), Some(default_value)) if gateway_value != default_value ) } fn to_config(&self) -> GatewayDataConfig { let database = self.effective_sql_database_config(); let config = match database { Some(database) => GatewayDataConfig::from_database_config(database), None => GatewayDataConfig::disabled(), }; match self.effective_encryption_key() { Some(value) => { warm_python_fernet_secret(&value); config.with_encryption_key(value) } None => config, } } } fn resolve_database_url( driver: Option, configured_url: Option, legacy_postgres_url: Option, generic_database_url: Option, ) -> Option { 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( long, env = "AETHER_GATEWAY_USAGE_QUEUE_TERMINAL_EVENTS", default_value_t = true )] queue_terminal_events: bool, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_LIFECYCLE_EVENTS", default_value_t = true )] queue_lifecycle_events: bool, #[arg(long, env = "AETHER_GATEWAY_USAGE_QUEUE_WORKERS", value_name = "COUNT")] queue_workers: Option, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_WORKER_AUTOSCALE_ENABLED", default_value_t = true )] queue_worker_autoscale_enabled: bool, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_WORKER_MAX_COUNT", value_name = "COUNT", default_value = "32" )] queue_worker_max_count: Option, #[arg( long, env = "AETHER_GATEWAY_USAGE_WORKER_RECORD_CONCURRENCY_LIMIT", value_name = "COUNT", default_value = "32" )] worker_record_concurrency_limit: Option, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_WORKER_SCALE_INTERVAL_MS", default_value_t = 1_000 )] queue_worker_scale_interval_ms: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_WORKER_IDLE_SCALE_DOWN_TICKS", default_value_t = 30 )] queue_worker_idle_scale_down_ticks: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_STREAM_KEY", default_value = "usage:events" )] queue_stream_key: String, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_GROUP", default_value = "usage_consumers" )] queue_group: String, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_DLQ_STREAM_KEY", default_value = "usage:events:dlq" )] queue_dlq_stream_key: String, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_STREAM_MAXLEN", default_value_t = 200_000 )] queue_stream_maxlen: usize, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_BATCH_SIZE", default_value_t = 128 )] queue_batch_size: usize, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_BLOCK_MS", default_value_t = 500 )] queue_block_ms: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_RECLAIM_IDLE_MS", default_value_t = 60_000 )] queue_reclaim_idle_ms: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_RECLAIM_COUNT", default_value_t = 128 )] queue_reclaim_count: usize, #[arg( long, env = "AETHER_GATEWAY_USAGE_QUEUE_RECLAIM_INTERVAL_MS", default_value_t = 5_000 )] queue_reclaim_interval_ms: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_TERMINAL_SUBMISSION_MAX_IN_FLIGHT", default_value_t = 1_024 )] terminal_submission_max_in_flight: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_TERMINAL_ENQUEUE_MAX_IN_FLIGHT", default_value_t = 1_024 )] terminal_enqueue_max_in_flight: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_LIFECYCLE_ENQUEUE_MAX_IN_FLIGHT", default_value_t = 512 )] lifecycle_enqueue_max_in_flight: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_LIFECYCLE_ENQUEUE_DELAY_MS", default_value_t = 1_000 )] lifecycle_enqueue_delay_ms: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_RETRY_DEFERRED_LIFECYCLE_EVENTS", default_value_t = true )] retry_deferred_lifecycle_events: bool, #[arg( long, env = "AETHER_GATEWAY_USAGE_ENQUEUE_RETRY_BUFFER_CAPACITY", default_value_t = 131_072 )] enqueue_retry_buffer_capacity: usize, #[arg( long, env = "AETHER_GATEWAY_USAGE_ENQUEUE_RETRY_WORKERS", default_value_t = 8 )] enqueue_retry_workers: usize, #[arg( long, env = "AETHER_GATEWAY_USAGE_ENQUEUE_RETRY_INITIAL_BACKOFF_MS", default_value_t = 3_000 )] enqueue_retry_initial_backoff_ms: u64, #[arg( long, env = "AETHER_GATEWAY_USAGE_ENQUEUE_RETRY_MAX_BACKOFF_MS", default_value_t = 10_000 )] enqueue_retry_max_backoff_ms: u64, } impl GatewayUsageArgs { fn effective_queue_workers( &self, node_role: NodeRoleArg, max_in_flight_requests: Option, distributed_request_limit: Option, database: Option<&SqlDatabaseConfig>, database_is_isolated: bool, ) -> usize { if let Some(queue_workers) = self.queue_workers { return queue_workers.clamp(1, MAX_USAGE_QUEUE_WORKERS); } if !self.queue_terminal_events && !self.queue_lifecycle_events { return 1; } automatic_usage_queue_workers( node_role, max_in_flight_requests, distributed_request_limit, database, database_is_isolated, ) } fn effective_queue_worker_max_count( &self, node_role: NodeRoleArg, database: Option<&SqlDatabaseConfig>, worker_count: usize, database_is_isolated: bool, ) -> usize { if !self.queue_worker_autoscale_enabled { return worker_count.clamp(1, MAX_USAGE_QUEUE_WORKERS); } self.queue_worker_max_count .unwrap_or_else(|| { usage_queue_worker_database_cap(node_role, database, database_is_isolated) }) .max(1) .min(usage_queue_worker_database_cap( node_role, database, database_is_isolated, )) .clamp(worker_count.max(1), MAX_USAGE_QUEUE_WORKERS) } fn runtime_state_blocking_stream_lanes( &self, node_role: NodeRoleArg, database: Option<&SqlDatabaseConfig>, worker_max_count: usize, ) -> Option { if !node_role.spawns_background_tasks() || (!self.queue_terminal_events && !self.queue_lifecycle_events) || database.is_none() { return None; } Some(worker_max_count.clamp(1, MAX_USAGE_QUEUE_WORKERS)) } fn effective_worker_record_concurrency_limit( &self, node_role: NodeRoleArg, database: Option<&SqlDatabaseConfig>, database_is_isolated: bool, ) -> Option { if let Some(limit) = self.worker_record_concurrency_limit { if limit == 0 { return None; } return Some( limit .min(MAX_USAGE_QUEUE_WORKERS) .min( usage_worker_record_concurrency_database_cap( node_role, database, database_is_isolated, ) .unwrap_or(MAX_USAGE_QUEUE_WORKERS), ) .max(1), ); } if !node_role.spawns_background_tasks() || (!self.queue_terminal_events && !self.queue_lifecycle_events) { return None; } usage_worker_record_concurrency_database_cap(node_role, database, database_is_isolated) } fn to_config( &self, worker_count: usize, worker_max_count: usize, worker_record_concurrency_limit: Option, ) -> UsageRuntimeConfig { UsageRuntimeConfig { enabled: true, queue_terminal_events: self.queue_terminal_events, queue_lifecycle_events: self.queue_lifecycle_events, worker_count: worker_count.clamp(1, MAX_USAGE_QUEUE_WORKERS), worker_autoscale_enabled: self.queue_worker_autoscale_enabled, worker_max_count: worker_max_count.clamp(worker_count.max(1), MAX_USAGE_QUEUE_WORKERS), worker_record_concurrency_limit, worker_scale_interval_ms: self.queue_worker_scale_interval_ms.max(1), worker_idle_scale_down_ticks: self.queue_worker_idle_scale_down_ticks.max(1), stream_key: self.queue_stream_key.trim().to_string(), consumer_group: self.queue_group.trim().to_string(), dlq_stream_key: self.queue_dlq_stream_key.trim().to_string(), stream_maxlen: self.queue_stream_maxlen.max(1), consumer_batch_size: self.queue_batch_size.max(1), consumer_block_ms: self.queue_block_ms.max(1), reclaim_idle_ms: self.queue_reclaim_idle_ms.max(1), reclaim_count: self.queue_reclaim_count.max(1), reclaim_interval_ms: self.queue_reclaim_interval_ms.max(1), terminal_submission_max_in_flight: self.terminal_submission_max_in_flight.max(1), terminal_enqueue_max_in_flight: self.terminal_enqueue_max_in_flight.max(1), lifecycle_enqueue_max_in_flight: self.lifecycle_enqueue_max_in_flight.max(1), lifecycle_enqueue_delay_ms: self.lifecycle_enqueue_delay_ms, retry_deferred_lifecycle_events: self.retry_deferred_lifecycle_events, enqueue_retry_buffer_capacity: self.enqueue_retry_buffer_capacity.max(1), enqueue_retry_workers: self.enqueue_retry_workers.clamp(1, 64), enqueue_retry_initial_backoff_ms: self.enqueue_retry_initial_backoff_ms.max(1), enqueue_retry_max_backoff_ms: self .enqueue_retry_max_backoff_ms .max(self.enqueue_retry_initial_backoff_ms.max(1)), } } } #[derive(ClapArgs, Debug, Clone)] struct GatewayFrontdoorArgs { #[arg(long, env = "ENVIRONMENT", default_value = "development")] environment: String, #[arg(long, env = "CORS_ORIGINS")] cors_origins: Option, #[arg(long, env = "CORS_ALLOW_CREDENTIALS", default_value_t = true)] cors_allow_credentials: bool, } impl GatewayFrontdoorArgs { fn cors_config(&self) -> Option { FrontdoorCorsConfig::from_environment( self.environment.trim(), self.cors_origins.as_deref(), self.cors_allow_credentials, ) } } #[derive(ClapArgs, Debug, Clone)] struct GatewayRateLimitArgs { #[arg(long, env = "RPM_BUCKET_SECONDS", default_value_t = 60)] bucket_seconds: u64, #[arg(long, env = "RPM_KEY_TTL_SECONDS", default_value_t = 120)] key_ttl_seconds: u64, #[arg(long, env = "RATE_LIMIT_FAIL_OPEN", default_value_t = true)] fail_open: bool, } impl GatewayRateLimitArgs { fn config(&self) -> FrontdoorUserRpmConfig { FrontdoorUserRpmConfig::new(self.bucket_seconds, self.key_ttl_seconds, self.fail_open) } } #[derive(ClapArgs, Debug, Clone)] struct GatewayLoggingArgs { #[arg(long, env = "AETHER_LOG_FORMAT", value_enum, default_value = "pretty")] log_format: GatewayLogFormatArg, #[arg( long, env = "AETHER_LOG_DESTINATION", value_enum, default_value = "stdout" )] log_destination: GatewayLogDestinationArg, #[arg(long, env = "AETHER_LOG_DIR")] log_dir: Option, #[arg(long, env = "AETHER_LOG_ROTATION", value_enum, default_value = "daily")] log_rotation: GatewayLogRotationArg, #[arg(long, env = "AETHER_LOG_RETENTION_DAYS", default_value_t = 7)] log_retention_days: u64, #[arg(long, env = "AETHER_LOG_MAX_FILES", default_value_t = 30)] log_max_files: usize, } #[derive(Subcommand, Debug, Clone)] enum DataCommand { /// Export persistent SQL data to database-neutral JSONL. Export(DataExportArgs), /// Import database-neutral JSONL into the selected SQL database. Import(DataImportArgs), /// Copy persistent SQL data directly between two databases without a JSONL file. Copy(DataCopyArgs), } #[derive(ClapArgs, Debug, Clone)] struct DataExportArgs { #[command(flatten)] data: GatewayDataArgs, #[arg(long)] output: PathBuf, #[arg(long, value_enum, value_delimiter = ',')] domains: Vec, } #[derive(ClapArgs, Debug, Clone)] struct DataImportArgs { #[command(flatten)] data: GatewayDataArgs, #[arg(long)] input: PathBuf, } #[derive(ClapArgs, Debug, Clone)] struct DataCopyArgs { #[arg(long, value_enum)] source_driver: DatabaseDriverArg, #[arg(long)] source_url: String, #[arg(long, value_enum)] target_driver: DatabaseDriverArg, #[arg(long)] target_url: String, #[arg(long, value_enum, value_delimiter = ',')] domains: Vec, #[arg(long)] omit_request_body_details: bool, } impl GatewayLoggingArgs { fn apply_to_runtime_config( &self, mut config: ServiceRuntimeConfig, ) -> Result { config = config .with_log_format(self.log_format.into()) .with_log_destination(self.log_destination.into()); if matches!( self.log_destination, GatewayLogDestinationArg::File | GatewayLogDestinationArg::Both ) { let log_dir = self .log_dir .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, "AETHER_LOG_DIR is required when AETHER_LOG_DESTINATION=file|both", ) })?; config = config.with_file_logging(FileLoggingConfig::new( log_dir, self.log_rotation.into(), self.log_retention_days, self.log_max_files, )); } Ok(config) } } #[derive(Parser, Debug)] #[command( name = "aether-gateway", about = "Phase 3a Rust ingress gateway for Aether" )] struct Args { #[command(subcommand)] command: Option, #[arg(long, env = "APP_PORT", default_value_t = 8084)] app_port: u16, #[arg( long, env = "AETHER_GATEWAY_LISTEN_BACKLOG", default_value_t = DEFAULT_GATEWAY_LISTEN_BACKLOG )] listen_backlog: i32, #[arg( long, env = "AETHER_GATEWAY_LISTENER_SHARDS", default_value_t = DEFAULT_GATEWAY_LISTENER_SHARDS )] /// Number of SO_REUSEPORT listener shards. 0 selects a high-concurrency default. listener_shards: usize, #[arg( long, env = "AETHER_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS", default_value_t = DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS )] http2_max_concurrent_streams: u32, /// 容器内健康检查入口:根据当前 bind 端口探测本地 /health。 #[arg(long, hide = true, default_value_t = false)] healthcheck: bool, #[arg( long, hide = true, env = "AETHER_GATEWAY_HEALTHCHECK_TIMEOUT_MS", default_value_t = 3_000 )] healthcheck_timeout_ms: u64, #[arg( long, env = "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY", value_enum, default_value = "single-node" )] deployment_topology: DeploymentTopologyArg, #[arg( long, env = "AETHER_GATEWAY_NODE_ROLE", value_enum, default_value = "all" )] node_role: NodeRoleArg, #[arg(long, default_value_t = false)] migrate: bool, #[arg(long, default_value_t = false)] apply_backfills: bool, #[arg( long, env = "AETHER_GATEWAY_AUTO_PREPARE_DATABASE", default_value_t = false )] auto_prepare_database: bool, /// Path to frontend static files directory (SPA). When set, the gateway /// serves the frontend directly without nginx. #[arg(long, env = "AETHER_GATEWAY_STATIC_DIR")] static_dir: Option, #[arg( long, env = "AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE", value_enum, default_value = "python-sync-report" )] video_task_truth_source_mode: VideoTaskTruthSourceArg, #[arg( long, env = "AETHER_GATEWAY_VIDEO_TASK_POLLER_INTERVAL_MS", default_value_t = 5000 )] video_task_poller_interval_ms: u64, #[arg( long, env = "AETHER_GATEWAY_VIDEO_TASK_POLLER_BATCH_SIZE", default_value_t = 32 )] video_task_poller_batch_size: usize, #[arg(long, env = "AETHER_GATEWAY_VIDEO_TASK_STORE_PATH")] video_task_store_path: Option, #[arg(long, env = "AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS")] max_in_flight_requests: Option, /// Maximum number of long-lived public WebSocket connections. When unset, /// this follows `max_in_flight_requests` while remaining an independent /// gate. Set `AETHER_GATEWAY_MAX_WEBSOCKET_CONNECTIONS` to override it. #[arg(long, env = "AETHER_GATEWAY_MAX_WEBSOCKET_CONNECTIONS")] max_websocket_connections: Option, #[arg(long, env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_LIMIT")] distributed_request_limit: Option, /// Optional distributed limit for long-lived WebSocket connections. When /// omitted, the distributed request limit is reused; set it to 0 to keep /// WebSocket admission local-only. #[arg(long, env = "AETHER_GATEWAY_DISTRIBUTED_WEBSOCKET_CONNECTION_LIMIT")] distributed_websocket_connection_limit: Option, #[arg(long, env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_REDIS_URL")] distributed_request_redis_url: Option, #[arg(long, env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX")] distributed_request_redis_key_prefix: Option, #[arg( long, env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_LEASE_TTL_MS", default_value_t = 30_000 )] distributed_request_lease_ttl_ms: u64, #[arg( long, env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS", default_value_t = 10_000 )] distributed_request_renew_interval_ms: u64, #[arg( long, env = "AETHER_GATEWAY_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS", default_value_t = 1_000 )] distributed_request_command_timeout_ms: u64, #[arg(long, env = "AETHER_RUNTIME_BACKEND", value_enum)] runtime_backend: Option, #[arg(long, env = "AETHER_RUNTIME_REDIS_URL")] runtime_redis_url: Option, #[arg(long, env = "AETHER_RUNTIME_REDIS_KEY_PREFIX")] runtime_redis_key_prefix: Option, #[arg( long, env = "AETHER_RUNTIME_COMMAND_TIMEOUT_MS", default_value_t = 2_000 )] runtime_command_timeout_ms: u64, #[command(flatten)] data: GatewayDataArgs, #[command(flatten)] usage: GatewayUsageArgs, #[command(flatten)] frontdoor: GatewayFrontdoorArgs, #[command(flatten)] rate_limit: GatewayRateLimitArgs, #[command(flatten)] logging: GatewayLoggingArgs, } impl Args { fn effective_runtime_backend( &self, database: Option<&SqlDatabaseConfig>, data_redis_url: Option<&str>, ) -> RuntimeBackendArg { if let Some(runtime_backend) = self.runtime_backend { if !matches!(runtime_backend, RuntimeBackendArg::Auto) { return runtime_backend; } } if matches!(self.deployment_topology, DeploymentTopologyArg::MultiNode) { return RuntimeBackendArg::Redis; } if database.is_some_and(|database| database.driver == DatabaseDriver::Sqlite) { return RuntimeBackendArg::Memory; } if data_redis_url.is_some() { RuntimeBackendArg::Redis } else { RuntimeBackendArg::Memory } } fn effective_runtime_redis_url(&self, data_redis_url: Option<&str>) -> Option { self.runtime_redis_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| data_redis_url.map(ToOwned::to_owned)) } fn effective_runtime_redis_key_prefix(&self) -> Option { self.runtime_redis_key_prefix .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| { self.data .redis_key_prefix .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) }) } fn runtime_state_config( &self, runtime_backend: RuntimeBackendArg, data_redis_url: Option<&str>, blocking_stream_lanes: Option, ) -> RuntimeStateConfig { let redis = self .effective_runtime_redis_url(data_redis_url) .map(|url| RedisClientConfig { url, key_prefix: self.effective_runtime_redis_key_prefix(), }); RuntimeStateConfig { backend: runtime_backend.to_runtime_state_backend(), redis, command_timeout_ms: Some(self.runtime_command_timeout_ms.max(1)), blocking_stream_lanes, ..RuntimeStateConfig::default() } } fn runtime_config(&self) -> Result { let default_log_filter = if self.command.is_some() || self.migrate || self.apply_backfills || self.auto_prepare_database { "aether_gateway=info,aether_data=info" } else { "aether_gateway=info" }; let config = self .logging .apply_to_runtime_config(ServiceRuntimeConfig::new( "aether-gateway", default_log_filter, ))?; Ok(config .with_node_role(self.node_role.as_str()) .with_instance_id(resolve_gateway_log_instance_id())) } } fn resolve_gateway_log_instance_id() -> String { env_var_trimmed("AETHER_GATEWAY_INSTANCE_ID") .or_else(|| env_var_trimmed("HOSTNAME")) .unwrap_or_else(|| "local".to_string()) } fn validate_app_port(app_port: u16) -> Result { if app_port == 0 { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "APP_PORT must be between 1 and 65535", )); } Ok(app_port) } fn gateway_bind_addr(app_port: u16) -> Result { Ok(std::net::SocketAddr::from(( [0, 0, 0, 0], validate_app_port(app_port)?, ))) } fn gateway_listen_backlog(backlog: i32) -> i32 { backlog.clamp(MIN_GATEWAY_LISTEN_BACKLOG, MAX_GATEWAY_LISTEN_BACKLOG) } fn gateway_auto_listener_shards() -> usize { #[cfg(unix)] { std::thread::available_parallelism() .map(|parallelism| parallelism.get().saturating_mul(2)) .unwrap_or(16) .clamp(8, 16) .min(MAX_GATEWAY_LISTENER_SHARDS) } #[cfg(not(unix))] { 1 } } fn gateway_listener_shards(shards: usize) -> usize { if shards == 0 { return gateway_auto_listener_shards(); } shards.clamp(1, MAX_GATEWAY_LISTENER_SHARDS) } fn gateway_http2_max_concurrent_streams(streams: u32) -> u32 { streams.clamp( MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, ) } fn gateway_listener( bind_addr: std::net::SocketAddr, backlog: i32, reuse_port: bool, ) -> Result { let domain = match bind_addr { std::net::SocketAddr::V4(_) => socket2::Domain::IPV4, std::net::SocketAddr::V6(_) => socket2::Domain::IPV6, }; let socket = socket2::Socket::new(domain, socket2::Type::STREAM, Some(socket2::Protocol::TCP))?; socket.set_reuse_address(true)?; if reuse_port { set_gateway_listener_reuse_port(&socket)?; } socket.set_nonblocking(true)?; socket.set_tcp_nodelay(true)?; socket.bind(&bind_addr.into())?; socket.listen(gateway_listen_backlog(backlog))?; tokio::net::TcpListener::from_std(socket.into()) } #[cfg(unix)] fn set_gateway_listener_reuse_port(socket: &socket2::Socket) -> Result<(), std::io::Error> { socket.set_reuse_port(true) } #[cfg(not(unix))] fn set_gateway_listener_reuse_port(_socket: &socket2::Socket) -> Result<(), std::io::Error> { Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "AETHER_GATEWAY_LISTENER_SHARDS > 1 requires SO_REUSEPORT support", )) } fn gateway_listeners( bind_addr: std::net::SocketAddr, backlog: i32, shards: usize, ) -> Result, std::io::Error> { let shards = gateway_listener_shards(shards); let mut listeners = Vec::with_capacity(shards); for _ in 0..shards { listeners.push(gateway_listener(bind_addr, backlog, shards > 1)?); } Ok(listeners) } async fn serve_gateway_router( listeners: Vec, router: axum::Router, http2_max_concurrent_streams: u32, ) -> Result<(), Box> { let http2_max_concurrent_streams = gateway_http2_max_concurrent_streams(http2_max_concurrent_streams); let mut servers = tokio::task::JoinSet::new(); for listener in listeners { let router = router.clone(); servers.spawn(async move { serve_gateway_listener(listener, router, http2_max_concurrent_streams).await }); } if let Some(result) = servers.join_next().await { servers.abort_all(); let serve_result = result .map_err(|err| std::io::Error::other(format!("gateway listener task failed: {err}")))?; serve_result?; } Ok(()) } async fn serve_gateway_listener( listener: tokio::net::TcpListener, router: axum::Router, http2_max_concurrent_streams: u32, ) -> Result<(), std::io::Error> { let mut make_service = router.into_make_service_with_connect_info::(); loop { let (io, remote_addr) = listener.accept().await?; let tower_service = make_service .call(remote_addr) .await .unwrap_or_else(|err| match err {}) .map_request(|req: Request| req.map(Body::new)); let hyper_service = TowerToHyperService::new(tower_service); let io = TokioIo::new(io); tokio::spawn(async move { let mut builder = HyperServerBuilder::new(TokioExecutor::new()); builder.http2().enable_connect_protocol(); builder .http2() .max_concurrent_streams(http2_max_concurrent_streams); if let Err(err) = builder .serve_connection_with_upgrades(io, hyper_service) .await { tracing::trace!(error = ?err, "gateway connection closed with error"); } }); } } fn resolve_local_http_base_url(app_port: u16) -> Result { Ok(format!("http://127.0.0.1:{}", validate_app_port(app_port)?)) } fn resolve_healthcheck_url(app_port: u16) -> Result { Ok(format!("{}/health", resolve_local_http_base_url(app_port)?)) } async fn run_healthcheck( app_port: u16, healthcheck_timeout_ms: u64, ) -> Result<(), Box> { let url = resolve_healthcheck_url(app_port)?; reqwest::Client::builder() .timeout(std::time::Duration::from_millis( healthcheck_timeout_ms.max(1), )) .build()? .get(url) .send() .await? .error_for_status()?; Ok(()) } fn validate_deployment_topology( args: &Args, database: Option<&SqlDatabaseConfig>, data_redis_url: Option<&str>, runtime_backend: RuntimeBackendArg, ) -> Result<(), std::io::Error> { if matches!(args.deployment_topology, DeploymentTopologyArg::SingleNode) { if database.is_none() && data_redis_url.is_none() { warn!( "single-node deployment is starting without SQL database or Redis; local-only mode is allowed, but admin/auth/billing persistence will be limited" ); } if matches!(runtime_backend, RuntimeBackendArg::Redis) && data_redis_url.is_none() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "AETHER_RUNTIME_BACKEND=redis requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL", )); } return Ok(()); } if matches!(args.node_role, NodeRoleArg::All) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "AETHER_GATEWAY_NODE_ROLE=all is only valid for single-node deployment; use frontdoor or background when AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node", )); } let mut missing = Vec::new(); if database.is_none() { missing.push("AETHER_DATABASE_URL, DATABASE_URL, or AETHER_GATEWAY_DATA_POSTGRES_URL"); } if data_redis_url.is_none() { missing.push("REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL"); } if !missing.is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "multi-node deployment requires shared data backends; missing {}", missing.join(", ") ), )); } if matches!(runtime_backend, RuntimeBackendArg::Memory) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "AETHER_RUNTIME_BACKEND=memory is only valid for single-node deployment", )); } if database.is_some_and(|database| database.driver == DatabaseDriver::Sqlite) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "AETHER_DATABASE_DRIVER=sqlite is only valid for single-node deployment", )); } if args .video_task_store_path .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) { 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 SQL-backed state instead", )); } if env_var_trimmed("AETHER_GATEWAY_INSTANCE_ID").is_none() { warn!( "multi-node deployment started without AETHER_GATEWAY_INSTANCE_ID; this is acceptable for stateless frontdoor replicas, but tunnel owner routing should set an explicit per-node instance id" ); } if env_var_trimmed("AETHER_TUNNEL_RELAY_BASE_URL").is_none() { warn!( "multi-node deployment started without AETHER_TUNNEL_RELAY_BASE_URL; frontdoor replicas are fine, but proxy tunnel owner relay cannot forward across nodes until a per-node reachable base URL is configured" ); } if !matches!( args.video_task_truth_source_mode, VideoTaskTruthSourceArg::RustAuthoritative ) { warn!( "multi-node deployment is still using python-sync-report video task truth source; keep rust-authoritative as the long-term cluster baseline" ); } Ok(()) } fn main() -> Result<(), Box> { tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(GATEWAY_TOKIO_WORKER_STACK_SIZE_BYTES) .build()? .block_on(run()) } async fn run() -> Result<(), Box> { let args = Args::parse(); if let Some(command) = args.command.as_ref() { init_service_runtime(args.runtime_config()?)?; return run_data_command(command).await; } if args.migrate { init_service_runtime(args.runtime_config()?)?; return run_explicit_migrations(&args).await; } if args.apply_backfills { init_service_runtime(args.runtime_config()?)?; return run_explicit_backfills(&args).await; } let app_port = validate_app_port(args.app_port)?; let bind_addr = gateway_bind_addr(app_port)?; set_gateway_frontdoor_app_port(app_port); if args.healthcheck { return run_healthcheck(app_port, args.healthcheck_timeout_ms).await; } init_service_runtime(args.runtime_config()?)?; let sql_database_config = args.data.effective_sql_database_config(); 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()); let runtime_redis_url = args.effective_runtime_redis_url(data_redis_url.as_deref()); validate_deployment_topology( &args, sql_database_config.as_ref(), runtime_redis_url.as_deref(), runtime_backend, )?; let data_config = args.data.to_config(); let isolate_background_database = args.node_role.isolates_background_database(); let background_database_config = if isolate_background_database { data_config.background_database_config() } else { None }; let usage_database_is_isolated = isolate_background_database && background_database_config.is_some(); let usage_database_config = usage_database_config_for_role( args.node_role, data_config.database(), background_database_config.as_ref(), ); let request_concurrency_limit = args .max_in_flight_requests .filter(|limit| *limit > 0) .unwrap_or_else(automatic_gateway_request_concurrency); let websocket_connection_limit = args .max_websocket_connections .filter(|limit| *limit > 0) .unwrap_or(request_concurrency_limit); let distributed_websocket_connection_limit = match args.distributed_websocket_connection_limit { Some(limit) if limit > 0 => Some(limit), Some(_) => None, None => args.distributed_request_limit.filter(|limit| *limit > 0), }; let usage_queue_request_concurrency_hint = usage_queue_request_concurrency_hint( Some(request_concurrency_limit), args.distributed_request_limit, ); let usage_queue_request_concurrency_hint_source = if args.max_in_flight_requests.is_some() || args.distributed_request_limit.is_some() { "explicit" } else { "auto" }; let usage_queue_workers = args.usage.effective_queue_workers( args.node_role, Some(request_concurrency_limit), args.distributed_request_limit, usage_database_config, usage_database_is_isolated, ); let usage_queue_worker_max_count = args.usage.effective_queue_worker_max_count( args.node_role, usage_database_config, usage_queue_workers, usage_database_is_isolated, ); let usage_worker_record_concurrency_limit = args.usage.effective_worker_record_concurrency_limit( args.node_role, usage_database_config, usage_database_is_isolated, ); let usage_config = args.usage.to_config( usage_queue_workers, usage_queue_worker_max_count, usage_worker_record_concurrency_limit, ); let usage_blocking_stream_lanes = args.usage.runtime_state_blocking_stream_lanes( args.node_role, usage_database_config, usage_config.worker_max_count, ); let runtime_state = Arc::new( RuntimeState::from_config(args.runtime_state_config( runtime_backend, data_redis_url.as_deref(), usage_blocking_stream_lanes, )) .await .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string()))?, ); let rate_limit_config = if matches!(args.deployment_topology, DeploymentTopologyArg::MultiNode) { args.rate_limit.config().with_local_fallback(false) } else { args.rate_limit.config() }; if args.data.configured_encryption_key_mismatch() { warn!( "AETHER_GATEWAY_DATA_ENCRYPTION_KEY differs from ENCRYPTION_KEY; aether-gateway will prefer the gateway-specific value" ); } info!( event_name = "gateway_starting", log_type = "ops", bind = %bind_addr, app_port, environment = %args.frontdoor.environment, deployment_topology = args.deployment_topology.as_str(), node_role = args.node_role.as_str(), runtime_backend = runtime_backend.as_str(), usage_queue_workers = usage_config.worker_count, usage_queue_worker_autoscale_enabled = usage_config.worker_autoscale_enabled, usage_queue_worker_max_count = usage_config.worker_max_count, usage_worker_record_concurrency_limit = usage_config .worker_record_concurrency_limit .unwrap_or_default(), usage_queue_request_concurrency_hint = usage_queue_request_concurrency_hint.unwrap_or_default(), usage_queue_request_concurrency_hint_source, frontdoor_mode = "compatibility_frontdoor", log_format = ?args.logging.log_format, log_destination = args.logging.log_destination.as_str(), video_task_truth_source_mode = ?args.video_task_truth_source_mode, "aether-gateway starting" ); debug!( event_name = "gateway_startup_config", log_type = "ops", log_dir = args.logging.log_dir.as_deref().unwrap_or("-"), log_rotation = args.logging.log_rotation.as_str(), log_retention_days = args.logging.log_retention_days, log_max_files = args.logging.log_max_files, static_dir = args.static_dir.as_deref().unwrap_or("-"), cors_origins = args.frontdoor.cors_origins.as_deref().unwrap_or("-"), cors_allow_credentials = args.frontdoor.cors_allow_credentials, frontdoor_rpm_bucket_seconds = args.rate_limit.bucket_seconds, frontdoor_rpm_key_ttl_seconds = args.rate_limit.key_ttl_seconds, frontdoor_rpm_fail_open = args.rate_limit.fail_open, frontdoor_rpm_allow_local_fallback = rate_limit_config.allow_local_fallback(), video_task_poller_interval_ms = args.video_task_poller_interval_ms, video_task_poller_batch_size = args.video_task_poller_batch_size, video_task_store_path = args.video_task_store_path.as_deref().unwrap_or("-"), usage_queue_workers = usage_config.worker_count, usage_queue_workers_source = if args.usage.queue_workers.is_some() { "explicit" } else { "auto" }, usage_queue_worker_autoscale_enabled = usage_config.worker_autoscale_enabled, usage_queue_worker_max_count = usage_config.worker_max_count, usage_worker_record_concurrency_limit = usage_config .worker_record_concurrency_limit .unwrap_or_default(), usage_queue_request_concurrency_hint = usage_queue_request_concurrency_hint.unwrap_or_default(), usage_queue_request_concurrency_hint_source, max_in_flight_requests = request_concurrency_limit, max_in_flight_requests_source = if args.max_in_flight_requests.is_some() { "explicit" } else { "auto" }, distributed_request_limit = args.distributed_request_limit.unwrap_or_default(), max_websocket_connections = websocket_connection_limit, max_websocket_connections_source = if args.max_websocket_connections.is_some() { "explicit" } else { "request_concurrency_fallback" }, distributed_websocket_connection_limit = distributed_websocket_connection_limit.unwrap_or_default(), distributed_request_redis_configured = args .distributed_request_redis_url .as_deref() .or(runtime_redis_url.as_deref()) .is_some(), data_database_configured = sql_database_config.is_some(), data_database_driver = sql_database_config .as_ref() .map(|database| database.driver.as_str()) .unwrap_or("-"), data_database_pool_min_connections = sql_database_config .as_ref() .map(|database| database.pool.min_connections) .unwrap_or_default(), data_database_pool_max_connections = sql_database_config .as_ref() .map(|database| database.pool.max_connections) .unwrap_or_default(), 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(), data_postgres_require_ssl = args.data.postgres_require_ssl, "aether-gateway startup configuration" ); let mut state = AppState::new()? .with_runtime_state(runtime_state) .with_data_config_and_background_isolation(data_config, isolate_background_database)? .with_usage_runtime_config(usage_config)? .with_video_task_truth_source_mode(args.video_task_truth_source_mode.into()); if let Some(cors_config) = args.frontdoor.cors_config() { state = state.with_frontdoor_cors_config(cors_config); } state = state.with_frontdoor_user_rpm_config(rate_limit_config); if matches!( args.video_task_truth_source_mode, VideoTaskTruthSourceArg::RustAuthoritative ) { state = state.with_video_task_poller_config( std::time::Duration::from_millis(args.video_task_poller_interval_ms.max(1)), args.video_task_poller_batch_size.max(1), ); } if let Some(path) = args .video_task_store_path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { state = state.with_video_task_store_path(path)?; } state = state .with_request_concurrency_limit(request_concurrency_limit) .with_websocket_connection_limit(websocket_connection_limit); if let Some(limit) = args.distributed_request_limit.filter(|limit| *limit > 0) { let distributed_gate = state .runtime_state() .semaphore( "gateway_requests_distributed", limit, RuntimeSemaphoreConfig { lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1), renew_interval_ms: args.distributed_request_renew_interval_ms.max(1), command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)), }, ) .map_err(|err| { std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string()) })?; state = state.with_distributed_request_concurrency_gate(distributed_gate); } if let Some(limit) = distributed_websocket_connection_limit { let distributed_gate = state .runtime_state() .semaphore( "gateway_websocket_connections_distributed", limit, RuntimeSemaphoreConfig { lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1), renew_interval_ms: args.distributed_request_renew_interval_ms.max(1), command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)), }, ) .map_err(|err| { std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string()) })?; state = state.with_distributed_websocket_connection_gate(distributed_gate); } if matches!(args.deployment_topology, DeploymentTopologyArg::MultiNode) && !state.has_usage_data_writer() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "usage persistence requires a configured shared SQL data backend; set AETHER_DATABASE_DRIVER and AETHER_DATABASE_URL before starting aether-gateway", ) .into()); } if matches!(args.deployment_topology, DeploymentTopologyArg::SingleNode) && !state.has_usage_data_writer() { warn!( "usage persistence backend is not configured; single-node local-only mode will run without durable usage records" ); } info!( has_data_backends = state.has_data_backends(), has_video_task_data_reader = state.has_video_task_data_reader(), has_usage_data_writer = state.has_usage_data_writer(), has_usage_worker_backend = state.has_usage_worker_backend(), control_api_configured = true, execution_runtime_configured = state.execution_runtime_configured(), "aether-gateway data layer configured" ); prepare_database_startup_requirements(&state, args.auto_prepare_database).await?; state.warm_database_pools().await?; let reset_stale_proxy_nodes = state.reset_stale_proxy_node_tunnel_statuses().await?; if reset_stale_proxy_nodes > 0 { info!( reset_stale_proxy_nodes, "reset stale tunnel-connected proxy nodes on startup" ); } state.bootstrap_admin_from_env().await?; match state.ensure_system_default_routing_group().await { Ok(Some(group)) => { info!( group_id = %group.id, group_name = %group.name, "created system default routing group from legacy scheduler config" ); } Ok(None) => {} Err(err) => { warn!( error = %err, "failed to bootstrap system default routing group; scheduler falls back to legacy system config" ); } } match state.prewarm_chat_pii_redaction_runtime_config().await { Ok(enabled) => { info!( chat_pii_redaction_enabled = enabled, "prewarmed chat pii redaction runtime config" ); } Err(err) => { warn!( error = %err, "failed to prewarm chat pii redaction runtime config" ); } } match prewarm_direct_h2c_sender_cache_from_env_for_startup().await { Ok(Some(report)) => { if report.failed_targets > 0 { warn!( requested_urls = report.requested_urls, unique_targets = report.unique_targets, warmed_targets = report.warmed_targets, failed_targets = report.failed_targets, ready_required = report.ready_required, first_error = ?report.first_error, "direct h2c sender cache prewarm completed with failures" ); } else { info!( requested_urls = report.requested_urls, unique_targets = report.unique_targets, warmed_targets = report.warmed_targets, ready_required = report.ready_required, "direct h2c sender cache prewarmed" ); } } Ok(None) => {} Err(err) => { return Err(std::io::Error::other(err).into()); } } let background_tasks = if args.node_role.spawns_background_tasks() { Some(state.spawn_background_tasks()) } else { info!( node_role = args.node_role.as_str(), "background workers disabled for this node role" ); None }; if state.prewarm_metric_snapshot().await { info!("gateway metric snapshot prewarmed"); } else { warn!( "gateway metric snapshot prewarm did not complete; continuing with fail-open metrics" ); } let listen_backlog = gateway_listen_backlog(args.listen_backlog); let listener_shards = gateway_listener_shards(args.listener_shards); let listeners = gateway_listeners(bind_addr, listen_backlog, listener_shards)?; let public_base_url = resolve_local_http_base_url(app_port)?; let frontdoor_health_url = format!("{public_base_url}/_gateway/health"); let api_router = build_router_with_state(state); // Compose the final router: API routes + optional static file serving. let router = if let Some(ref static_dir) = args.static_dir { use tower_http::compression::CompressionLayer; info!(static_dir = %static_dir, "serving frontend static files"); attach_static_frontend(api_router, static_dir).layer(CompressionLayer::new()) } else { api_router }; info!( event_name = "gateway_ready", log_type = "ops", bind = %bind_addr, app_port, listen_backlog, listener_shards, http2_max_concurrent_streams = gateway_http2_max_concurrent_streams(args.http2_max_concurrent_streams), public_url = %public_base_url, healthcheck_url = %frontdoor_health_url, legacy_route_policy = "fail_closed", "aether-gateway ready" ); serve_gateway_router(listeners, router, args.http2_max_concurrent_streams).await?; if let Some(background_tasks) = background_tasks { background_tasks.shutdown().await; } Ok(()) } async fn run_data_command(command: &DataCommand) -> Result<(), Box> { match command { DataCommand::Export(args) => run_data_export(args).await, DataCommand::Import(args) => run_data_import(args).await, DataCommand::Copy(args) => run_data_copy(args).await, } } fn required_sql_database_config( data: &GatewayDataArgs, ) -> Result> { data.effective_sql_database_config().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, "AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL, AETHER_GATEWAY_DATA_POSTGRES_URL, or DATABASE_URL is required", ) .into() }) } fn requested_export_domains(args: &DataExportArgs) -> Vec { requested_domains(&args.domains) } fn requested_domains(domains: &[ExportDomainArg]) -> Vec { domains.iter().copied().map(Into::into).collect::>() } fn current_unix_secs() -> Result { Ok(std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_secs()) } async fn run_data_export(args: &DataExportArgs) -> Result<(), Box> { let database = required_sql_database_config(&args.data)?; let driver = database.driver; let domains = requested_export_domains(args); let created_at_unix_secs = current_unix_secs()?; let encoded = export_database_jsonl(database, domains, created_at_unix_secs).await?; tokio::fs::write(&args.output, encoded.as_bytes()).await?; info!( driver = %driver, output = %args.output.display(), bytes = encoded.len(), "database export complete" ); println!( "exported {} bytes from {} to {}", encoded.len(), driver, args.output.display() ); Ok(()) } async fn run_data_import(args: &DataImportArgs) -> Result<(), Box> { let database = required_sql_database_config(&args.data)?; let driver = database.driver; let input = tokio::fs::read_to_string(&args.input).await?; let imported = import_database_jsonl(database, &input).await?; info!( driver = %driver, input = %args.input.display(), imported, "database import complete" ); println!( "imported {} records into {} from {}", imported, driver, args.input.display() ); Ok(()) } fn copy_database_config( driver: DatabaseDriverArg, url: &str, label: &str, ) -> Result> { let url = url.trim(); if url.is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("{label} database URL must not be empty"), ) .into()); } let driver = DatabaseDriver::from(driver); Ok(SqlDatabaseConfig::new( driver, url, SqlPoolConfig { require_ssl: false, ..SqlPoolConfig::default() }, )?) } async fn run_data_copy(args: &DataCopyArgs) -> Result<(), Box> { let source = copy_database_config(args.source_driver, &args.source_url, "source")?; let target = copy_database_config(args.target_driver, &args.target_url, "target")?; let source_driver = source.driver; let target_driver = target.driver; let domains = requested_domains(&args.domains); let created_at_unix_secs = current_unix_secs()?; let imported = copy_database_records( source, target, domains, created_at_unix_secs, DataCopyOptions { omit_request_body_details: args.omit_request_body_details, }, ) .await?; info!( source_driver = %source_driver, target_driver = %target_driver, imported, "database copy complete" ); println!( "copied {} records from {} to {} without a JSONL file", imported, source_driver, target_driver ); Ok(()) } async fn run_explicit_migrations(args: &Args) -> Result<(), Box> { if args.data.effective_sql_database_config().is_none() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL, AETHER_GATEWAY_DATA_POSTGRES_URL, or DATABASE_URL is required when running --migrate", ) .into()); } if args.data.configured_encryption_key_mismatch() { warn!( "AETHER_GATEWAY_DATA_ENCRYPTION_KEY differs from ENCRYPTION_KEY; aether-gateway will prefer the gateway-specific value" ); } let state = AppState::new()?.with_data_config(args.data.to_config())?; let pending = state .pending_database_migrations() .await? .unwrap_or_default(); if pending.is_empty() { info!( pending_migrations = 0, "database migrations already up to date" ); return Ok(()); } let next = pending .first() .expect("pending migrations should have a first element"); info!( pending_migrations = pending.len(), next_version = next.version, next_description = %next.description, pending_versions = %format_pending_migrations(&pending), "running database migrations by explicit request..." ); if state.run_database_migrations().await? { info!("database migrations complete"); } Ok(()) } async fn run_explicit_backfills(args: &Args) -> Result<(), Box> { let database = args.data.effective_sql_database_config().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, "AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL, AETHER_GATEWAY_DATA_POSTGRES_URL, or DATABASE_URL is required when running --apply-backfills", ) })?; let state = AppState::new()?.with_data_config(args.data.to_config())?; ensure_database_schema_is_current(&state).await?; let pending = state .pending_database_backfills() .await? .unwrap_or_default(); if pending.is_empty() { info!( driver = %database.driver, pending_backfills = 0, "database backfills already up to date" ); return Ok(()); } let next = pending .first() .expect("pending backfills should have a first element"); info!( pending_backfills = pending.len(), next_version = next.version, next_description = %next.description, pending_versions = %format_pending_backfills(&pending), "running database backfills by explicit request..." ); if state.run_database_backfills().await? { info!("database backfills complete"); } Ok(()) } async fn prepare_database_startup_requirements( state: &AppState, auto_prepare_database: bool, ) -> Result<(), Box> { if !auto_prepare_database { ensure_database_schema_is_current(state).await?; ensure_database_backfills_are_current(state).await?; return Ok(()); } info!( "auto database preparation enabled; applying pending migrations and backfills before serving traffic" ); let Some(pending_migrations) = state.prepare_database_for_startup().await? else { return Ok(()); }; if !pending_migrations.is_empty() { let next = pending_migrations .first() .expect("pending migrations should have a first element"); info!( pending_migrations = pending_migrations.len(), next_version = next.version, next_description = %next.description, pending_versions = %format_pending_migrations(&pending_migrations), "running database migrations during service startup..." ); if state.run_database_migrations().await? { info!("database migrations complete during service startup"); } } let Some(pending_backfills) = state.pending_database_backfills().await? else { return Ok(()); }; if pending_backfills.is_empty() { return Ok(()); } let next = pending_backfills .first() .expect("pending backfills should have a first element"); info!( pending_backfills = pending_backfills.len(), next_version = next.version, next_description = %next.description, pending_versions = %format_pending_backfills(&pending_backfills), "running database backfills during service startup..." ); if state.run_database_backfills().await? { info!("database backfills complete during service startup"); } Ok(()) } fn format_pending_migrations( pending: &[aether_data::lifecycle::migrate::PendingMigrationInfo], ) -> String { pending .iter() .map(|migration| format!("{} ({})", migration.version, migration.description)) .collect::>() .join(", ") } fn format_pending_backfills( pending: &[aether_data::lifecycle::backfill::PendingBackfillInfo], ) -> String { pending .iter() .map(|backfill| format!("{} ({})", backfill.version, backfill.description)) .collect::>() .join(", ") } async fn ensure_database_backfills_are_current( state: &AppState, ) -> Result<(), Box> { let Some(pending) = state.pending_database_backfills().await? else { return Ok(()); }; if pending.is_empty() { return Ok(()); } let next = pending .first() .expect("pending backfills should have a first element"); Err(pending_backfills_error(pending.len(), next.version, &next.description).into()) } async fn ensure_database_schema_is_current( state: &AppState, ) -> Result<(), Box> { let Some(pending) = state.prepare_database_for_startup().await? else { return Ok(()); }; if pending.is_empty() { return Ok(()); } let next = pending .first() .expect("pending migrations should have a first element"); Err(pending_schema_error(pending.len(), next.version, &next.description).into()) } fn pending_schema_error( pending_count: usize, next_version: i64, next_description: &str, ) -> std::io::Error { std::io::Error::other(format!( "database schema is behind by {} migration(s); next pending migration is {} ({})\nrun `aether-gateway --migrate` before starting the service", pending_count, next_version, next_description )) } fn pending_backfills_error( pending_count: usize, next_version: i64, next_description: &str, ) -> std::io::Error { std::io::Error::other(format!( "database backfills are behind by {} backfill(s); next pending backfill is {} ({})\nrun `aether-gateway --apply-backfills` before starting the service", pending_count, next_version, next_description )) } #[cfg(test)] mod tests { use super::{ automatic_gateway_request_concurrency_for_capacity, automatic_gateway_request_concurrency_for_parallelism, automatic_sql_pool_config, automatic_sql_pool_config_for_parallelism, automatic_usage_queue_workers_for_parallelism, ensure_database_backfills_are_current, ensure_database_schema_is_current, pending_backfills_error, pending_schema_error, resolve_healthcheck_url, usage_database_config_for_role, Args, DatabaseDriverArg, DeploymentTopologyArg, GatewayDataArgs, GatewayFrontdoorArgs, GatewayLogDestinationArg, GatewayLogFormatArg, GatewayLogRotationArg, GatewayLoggingArgs, GatewayRateLimitArgs, GatewayUsageArgs, NodeRoleArg, RuntimeBackendArg, VideoTaskTruthSourceArg, DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, DEFAULT_GATEWAY_LISTENER_SHARDS, DEFAULT_GATEWAY_LISTEN_BACKLOG, MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, MAX_GATEWAY_LISTENER_SHARDS, MAX_GATEWAY_LISTEN_BACKLOG, MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, MIN_GATEWAY_LISTEN_BACKLOG, }; use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig}; use aether_gateway::AppState; fn test_args() -> Args { Args { command: None, app_port: 8084, listen_backlog: DEFAULT_GATEWAY_LISTEN_BACKLOG, listener_shards: DEFAULT_GATEWAY_LISTENER_SHARDS, http2_max_concurrent_streams: DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS, healthcheck: false, healthcheck_timeout_ms: 3_000, deployment_topology: DeploymentTopologyArg::SingleNode, node_role: NodeRoleArg::All, migrate: false, apply_backfills: false, auto_prepare_database: false, static_dir: None, video_task_truth_source_mode: VideoTaskTruthSourceArg::PythonSyncReport, video_task_poller_interval_ms: 5_000, video_task_poller_batch_size: 32, video_task_store_path: None, max_in_flight_requests: None, max_websocket_connections: None, distributed_request_limit: None, distributed_websocket_connection_limit: None, distributed_request_redis_url: None, distributed_request_redis_key_prefix: None, distributed_request_lease_ttl_ms: 30_000, distributed_request_renew_interval_ms: 10_000, distributed_request_command_timeout_ms: 1_000, runtime_backend: None, runtime_redis_url: None, runtime_redis_key_prefix: None, runtime_command_timeout_ms: 1_000, data: GatewayDataArgs { database_driver: None, database_url: None, postgres_url: None, encryption_key: None, redis_url: None, redis_key_prefix: None, postgres_min_connections: None, postgres_max_connections: None, postgres_acquire_timeout_ms: None, postgres_idle_timeout_ms: None, postgres_max_lifetime_ms: None, postgres_statement_cache_capacity: None, postgres_require_ssl: false, }, usage: GatewayUsageArgs { queue_terminal_events: true, queue_lifecycle_events: true, queue_workers: Some(4), queue_worker_autoscale_enabled: true, queue_worker_max_count: Some(32), worker_record_concurrency_limit: Some(32), queue_worker_scale_interval_ms: 1_000, queue_worker_idle_scale_down_ticks: 30, queue_stream_key: "usage:events".to_string(), queue_group: "usage_consumers".to_string(), queue_dlq_stream_key: "usage:events:dlq".to_string(), queue_stream_maxlen: 200_000, queue_batch_size: 128, queue_block_ms: 500, queue_reclaim_idle_ms: 60_000, queue_reclaim_count: 128, queue_reclaim_interval_ms: 5_000, terminal_submission_max_in_flight: 1_024, terminal_enqueue_max_in_flight: 1_024, lifecycle_enqueue_max_in_flight: 512, lifecycle_enqueue_delay_ms: 1_000, retry_deferred_lifecycle_events: true, enqueue_retry_buffer_capacity: 131_072, enqueue_retry_workers: 8, enqueue_retry_initial_backoff_ms: 3_000, enqueue_retry_max_backoff_ms: 10_000, }, frontdoor: GatewayFrontdoorArgs { environment: "development".to_string(), cors_origins: None, cors_allow_credentials: true, }, rate_limit: GatewayRateLimitArgs { bucket_seconds: 60, key_ttl_seconds: 120, fail_open: true, }, logging: GatewayLoggingArgs { log_format: GatewayLogFormatArg::Pretty, log_destination: GatewayLogDestinationArg::Stdout, log_dir: None, log_rotation: GatewayLogRotationArg::Daily, log_retention_days: 7, log_max_files: 30, }, } } fn test_database(driver: DatabaseDriver, max_connections: u32) -> SqlDatabaseConfig { let url = match driver { DatabaseDriver::Sqlite => "sqlite://./data/aether.db", DatabaseDriver::Mysql => "mysql://root:root@localhost/aether", DatabaseDriver::Postgres => "postgres://postgres:postgres@localhost/aether", }; let max_connections = max_connections.max(1); SqlDatabaseConfig::new( driver, url, SqlPoolConfig { min_connections: 1, max_connections, ..SqlPoolConfig::default() }, ) .expect("test database config should build") } #[test] fn resolves_healthcheck_url_from_app_port() { assert_eq!( resolve_healthcheck_url(8084).unwrap(), "http://127.0.0.1:8084/health" ); } #[test] fn rejects_zero_app_port() { let error = resolve_healthcheck_url(0).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); } #[test] fn clamps_gateway_listen_backlog() { assert_eq!( super::gateway_listen_backlog(MIN_GATEWAY_LISTEN_BACKLOG - 1), MIN_GATEWAY_LISTEN_BACKLOG ); assert_eq!( super::gateway_listen_backlog(DEFAULT_GATEWAY_LISTEN_BACKLOG), DEFAULT_GATEWAY_LISTEN_BACKLOG ); assert_eq!( super::gateway_listen_backlog(MAX_GATEWAY_LISTEN_BACKLOG + 1), MAX_GATEWAY_LISTEN_BACKLOG ); } #[test] fn clamps_gateway_listener_shards() { let auto_shards = super::gateway_listener_shards(0); assert!((1..=MAX_GATEWAY_LISTENER_SHARDS).contains(&auto_shards)); assert_eq!(super::gateway_listener_shards(1), 1); assert_eq!( super::gateway_listener_shards(DEFAULT_GATEWAY_LISTENER_SHARDS), auto_shards ); assert_eq!( super::gateway_listener_shards(MAX_GATEWAY_LISTENER_SHARDS + 1), MAX_GATEWAY_LISTENER_SHARDS ); } #[test] fn clamps_gateway_http2_max_concurrent_streams() { assert_eq!( super::gateway_http2_max_concurrent_streams( MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS - 1 ), MIN_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS ); assert_eq!( super::gateway_http2_max_concurrent_streams( DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS ), DEFAULT_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS ); assert_eq!( super::gateway_http2_max_concurrent_streams( MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS + 1 ), MAX_GATEWAY_HTTP2_MAX_CONCURRENT_STREAMS ); } #[test] fn auto_gateway_request_concurrency_scales_and_clamps() { assert_eq!( automatic_gateway_request_concurrency_for_parallelism(1), 1_024 ); assert_eq!( automatic_gateway_request_concurrency_for_parallelism(4), 4_096 ); assert_eq!( automatic_gateway_request_concurrency_for_parallelism(64), 65_536 ); } #[test] fn auto_gateway_request_concurrency_respects_fd_budget() { assert_eq!( automatic_gateway_request_concurrency_for_capacity(64, Some(16_384)), 8_064 ); assert_eq!( automatic_gateway_request_concurrency_for_capacity(64, Some(1_024)), 384 ); assert_eq!( automatic_gateway_request_concurrency_for_capacity(64, None), 65_536 ); } #[test] fn explicit_migrate_runtime_config_enables_data_logs() { let mut args = test_args(); args.migrate = true; let config = args.runtime_config().expect("runtime config should build"); assert_eq!( config.default_log_filter, "aether_gateway=info,aether_data=info" ); } #[test] fn normal_runtime_config_keeps_gateway_only_logs() { let config = test_args() .runtime_config() .expect("runtime config should build"); assert_eq!(config.default_log_filter, "aether_gateway=info"); } #[test] fn apply_backfills_runtime_config_enables_data_logs() { let mut args = test_args(); args.apply_backfills = true; let config = args.runtime_config().expect("runtime config should build"); assert_eq!( config.default_log_filter, "aether_gateway=info,aether_data=info" ); } #[test] fn auto_prepare_database_runtime_config_enables_data_logs() { let mut args = test_args(); args.auto_prepare_database = true; let config = args.runtime_config().expect("runtime config should build"); assert_eq!( config.default_log_filter, "aether_gateway=info,aether_data=info" ); } #[test] fn gateway_data_pool_auto_sizes_sqlite_to_single_connection() { let mut args = test_args(); args.data.database_driver = Some(DatabaseDriverArg::Sqlite); args.data.database_url = Some("sqlite://./data/aether.db".to_string()); let database = args .data .effective_sql_database_config() .expect("sqlite database config should build"); assert_eq!(database.driver, DatabaseDriver::Sqlite); assert_eq!(database.pool.min_connections, 1); 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(); args.data.database_driver = Some(DatabaseDriverArg::Postgres); args.data.database_url = Some("postgres://postgres:postgres@localhost/aether".to_string()); let database = args .data .effective_sql_database_config() .expect("postgres database config should build"); let auto = automatic_sql_pool_config(DatabaseDriver::Postgres); assert_eq!(database.driver, DatabaseDriver::Postgres); assert_eq!(database.pool.min_connections, auto.min_connections); assert_eq!(database.pool.max_connections, auto.max_connections); } #[test] fn gateway_data_pool_cpu_sizing_examples() { let two_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 2); assert_eq!(two_cpu.min_connections, 4); assert_eq!(two_cpu.max_connections, 32); let four_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 4); assert_eq!(four_cpu.min_connections, 4); assert_eq!(four_cpu.max_connections, 32); let eight_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 8); assert_eq!(eight_cpu.min_connections, 8); assert_eq!(eight_cpu.max_connections, 32); let sixteen_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 16); assert_eq!(sixteen_cpu.min_connections, 16); assert_eq!(sixteen_cpu.max_connections, 64); let many_cpu = automatic_sql_pool_config_for_parallelism(DatabaseDriver::Postgres, 32); assert_eq!(many_cpu.min_connections, 16); assert_eq!(many_cpu.max_connections, 100); } #[test] fn gateway_database_pool_isolation_and_usage_capacity_follow_role() { assert!(NodeRoleArg::All.isolates_background_database()); assert!(!NodeRoleArg::Frontdoor.isolates_background_database()); assert!(!NodeRoleArg::Background.isolates_background_database()); let database = test_database(DatabaseDriver::Postgres, 20); let isolated_background = test_database(DatabaseDriver::Postgres, 4); assert_eq!( usage_database_config_for_role( NodeRoleArg::All, Some(&database), Some(&isolated_background), ) .expect("all-role usage database") .pool .max_connections, 4 ); for role in [NodeRoleArg::Frontdoor, NodeRoleArg::Background] { assert_eq!( usage_database_config_for_role(role, Some(&database), Some(&isolated_background),) .expect("single-pool role usage database") .pool .max_connections, 20 ); } } #[test] fn gateway_usage_queue_workers_manual_override_wins_and_is_capped() { let mut args = test_args(); args.usage.queue_workers = Some(72); let database = test_database(DatabaseDriver::Postgres, 100); let workers = args.usage.effective_queue_workers( NodeRoleArg::All, Some(10_000), None, Some(&database), false, ); assert_eq!(workers, 64); assert_eq!(args.usage.to_config(workers, 64, Some(8)).worker_count, 64); } #[test] fn gateway_usage_queue_workers_auto_uses_cpu_default_without_concurrency_hint() { let database = test_database(DatabaseDriver::Postgres, 100); let workers = automatic_usage_queue_workers_for_parallelism( 4, NodeRoleArg::All, None, None, Some(&database), false, ); assert_eq!(workers, 4); } #[test] fn gateway_usage_queue_worker_autoscale_max_uses_database_cap() { let mut args = test_args(); args.usage.queue_workers = None; let database = test_database(DatabaseDriver::Postgres, 40); let workers = args.usage.effective_queue_workers( args.node_role, Some(1_024), None, Some(&database), false, ); let max_workers = args.usage.effective_queue_worker_max_count( args.node_role, Some(&database), workers, false, ); assert_eq!(workers, 8); assert_eq!(max_workers, 10); } #[test] fn gateway_usage_queue_worker_autoscale_max_respects_explicit_override() { let mut args = test_args(); args.usage.queue_workers = None; args.usage.queue_worker_max_count = Some(32); let database = test_database(DatabaseDriver::Postgres, 200); let workers = args.usage.effective_queue_workers( args.node_role, Some(1_024), None, Some(&database), false, ); let max_workers = args.usage.effective_queue_worker_max_count( args.node_role, Some(&database), workers, false, ); assert_eq!(workers, 8); assert_eq!(max_workers, 32); } #[test] fn gateway_usage_worker_record_concurrency_defaults_to_pool_reserve_share() { let args = test_args(); let database = test_database(DatabaseDriver::Postgres, 64); assert_eq!( args.usage.effective_worker_record_concurrency_limit( NodeRoleArg::All, Some(&database), false, ), Some(8) ); assert_eq!( args.usage.effective_worker_record_concurrency_limit( NodeRoleArg::Background, Some(&database), false, ), Some(16) ); } #[test] fn gateway_usage_isolated_database_uses_dedicated_capacity_once() { let mut args = test_args(); args.usage.queue_workers = None; let database = test_database(DatabaseDriver::Postgres, 64); let isolated_background = test_database(DatabaseDriver::Postgres, 8); let usage_database = usage_database_config_for_role( NodeRoleArg::All, Some(&database), Some(&isolated_background), ) .expect("isolated usage database"); let workers = args.usage.effective_queue_workers( NodeRoleArg::All, Some(5_000), None, Some(usage_database), true, ); let max_workers = args.usage.effective_queue_worker_max_count( NodeRoleArg::All, Some(usage_database), workers, true, ); assert_eq!(usage_database.pool.max_connections, 8); assert_eq!(workers, 7); assert_eq!(max_workers, 7); assert_eq!( args.usage.effective_worker_record_concurrency_limit( NodeRoleArg::All, Some(usage_database), true, ), Some(7) ); } #[test] fn gateway_usage_worker_record_concurrency_can_be_explicitly_disabled() { let mut args = test_args(); args.usage.worker_record_concurrency_limit = Some(0); let database = test_database(DatabaseDriver::Postgres, 64); assert_eq!( args.usage.effective_worker_record_concurrency_limit( NodeRoleArg::All, Some(&database), false, ), None ); } #[test] fn gateway_usage_queue_blocking_stream_lanes_only_expand_when_worker_can_spawn() { let database = test_database(DatabaseDriver::Postgres, 100); let args = test_args(); assert_eq!( args.usage .runtime_state_blocking_stream_lanes(NodeRoleArg::All, Some(&database), 10,), Some(10) ); assert_eq!( args.usage.runtime_state_blocking_stream_lanes( NodeRoleArg::Frontdoor, Some(&database), 10, ), None ); assert_eq!( args.usage .runtime_state_blocking_stream_lanes(NodeRoleArg::All, None, 10), None ); let mut disabled_queue_args = args; disabled_queue_args.usage.queue_terminal_events = false; disabled_queue_args.usage.queue_lifecycle_events = false; assert_eq!( disabled_queue_args .usage .runtime_state_blocking_stream_lanes(NodeRoleArg::All, Some(&database), 10,), None ); } #[test] fn gateway_usage_queue_workers_auto_scales_from_request_concurrency() { let database = test_database(DatabaseDriver::Postgres, 100); let workers = automatic_usage_queue_workers_for_parallelism( 8, NodeRoleArg::All, Some(1_536), None, Some(&database), false, ); assert_eq!(workers, 12); } #[test] fn gateway_usage_queue_workers_auto_respects_effective_request_limit() { let database = test_database(DatabaseDriver::Postgres, 100); let workers = automatic_usage_queue_workers_for_parallelism( 8, NodeRoleArg::All, Some(2_048), Some(256), Some(&database), false, ); assert_eq!(workers, 2); } #[test] fn gateway_usage_queue_workers_auto_is_capped_by_database_pool() { let database = test_database(DatabaseDriver::Postgres, 20); let workers = automatic_usage_queue_workers_for_parallelism( 16, NodeRoleArg::All, Some(5_000), None, Some(&database), false, ); assert_eq!(workers, 5); } #[test] fn gateway_usage_queue_workers_auto_gives_background_nodes_more_pool_budget() { let database = test_database(DatabaseDriver::Postgres, 20); let workers = automatic_usage_queue_workers_for_parallelism( 16, NodeRoleArg::Background, Some(5_000), None, Some(&database), false, ); assert_eq!(workers, 10); } #[test] fn gateway_usage_queue_workers_auto_uses_single_worker_for_sqlite() { let database = test_database(DatabaseDriver::Sqlite, 1); let workers = automatic_usage_queue_workers_for_parallelism( 16, NodeRoleArg::All, Some(5_000), None, Some(&database), false, ); assert_eq!(workers, 1); } #[test] fn gateway_data_pool_explicit_values_override_auto_sizing() { let mut args = test_args(); args.data.database_driver = Some(DatabaseDriverArg::Sqlite); args.data.database_url = Some("sqlite://./data/aether.db".to_string()); args.data.postgres_min_connections = Some(2); args.data.postgres_max_connections = Some(8); args.data.postgres_acquire_timeout_ms = Some(2_000); let database = args .data .effective_sql_database_config() .expect("sqlite database config should build"); assert_eq!(database.pool.min_connections, 2); assert_eq!(database.pool.max_connections, 8); assert_eq!(database.pool.acquire_timeout_ms, 2_000); } #[test] fn gateway_data_pool_partial_max_override_clamps_auto_minimum() { let mut args = test_args(); args.data.database_driver = Some(DatabaseDriverArg::Postgres); args.data.database_url = Some("postgres://postgres:postgres@localhost/aether".to_string()); args.data.postgres_max_connections = Some(2); let database = args .data .effective_sql_database_config() .expect("postgres database config should build"); assert_eq!(database.pool.min_connections, 2); assert_eq!(database.pool.max_connections, 2); } #[test] fn gateway_data_pool_partial_min_override_raises_auto_maximum() { let mut args = test_args(); args.data.database_driver = Some(DatabaseDriverArg::Postgres); args.data.database_url = Some("postgres://postgres:postgres@localhost/aether".to_string()); args.data.postgres_min_connections = Some(128); let database = args .data .effective_sql_database_config() .expect("postgres database config should build"); assert_eq!(database.pool.min_connections, 128); assert_eq!(database.pool.max_connections, 128); } #[test] fn sqlite_database_defaults_to_memory_runtime_backend() { let args = test_args(); let database = SqlDatabaseConfig::new( DatabaseDriver::Sqlite, "sqlite://./data/aether.db".to_string(), SqlPoolConfig::default(), ) .expect("sqlite config should build"); assert_eq!( args.effective_runtime_backend(Some(&database), Some("redis://127.0.0.1/0")), RuntimeBackendArg::Memory ); } #[test] fn memory_runtime_data_config_keeps_redis_out_of_data_layer() { let mut args = test_args(); args.data.database_driver = Some(DatabaseDriverArg::Sqlite); args.data.database_url = Some("sqlite://./data/aether.db".to_string()); args.data.redis_url = Some("redis://127.0.0.1/0".to_string()); let config = args.data.to_config(); assert_eq!( config .database() .expect("database should be configured") .driver, DatabaseDriver::Sqlite ); } #[test] fn redis_runtime_config_owns_redis_connection() { let mut args = test_args(); args.data.database_driver = Some(DatabaseDriverArg::Postgres); args.data.database_url = Some("postgres://postgres:postgres@localhost/aether".to_string()); args.data.redis_url = Some("redis://127.0.0.1/0".to_string()); let config = args.runtime_state_config( RuntimeBackendArg::Redis, args.data.effective_redis_url().as_deref(), Some(7), ); assert_eq!(config.blocking_stream_lanes, Some(7)); assert_eq!( config .redis .as_ref() .expect("redis should be configured for runtime state") .url, "redis://127.0.0.1/0" ); } #[test] fn redis_url_defaults_to_redis_runtime_backend_for_server_database() { let args = test_args(); let database = SqlDatabaseConfig::new( DatabaseDriver::Postgres, "postgres://postgres:postgres@localhost/aether".to_string(), SqlPoolConfig::default(), ) .expect("postgres config should build"); assert_eq!( args.effective_runtime_backend(Some(&database), Some("redis://127.0.0.1/0")), RuntimeBackendArg::Redis ); } #[test] fn mysql_database_with_redis_defaults_to_redis_runtime_backend() { let args = test_args(); let database = SqlDatabaseConfig::new( DatabaseDriver::Mysql, "mysql://aether:aether@localhost:3306/aether".to_string(), SqlPoolConfig::default(), ) .expect("mysql config should build"); assert_eq!( args.effective_runtime_backend(Some(&database), Some("redis://127.0.0.1/0")), RuntimeBackendArg::Redis ); } #[test] fn sqlite_database_allows_explicit_redis_runtime_backend_when_redis_is_configured() { let mut args = test_args(); args.runtime_backend = Some(RuntimeBackendArg::Redis); let database = SqlDatabaseConfig::new( DatabaseDriver::Sqlite, "sqlite://./data/aether.db".to_string(), SqlPoolConfig::default(), ) .expect("sqlite config should build"); assert_eq!( args.effective_runtime_backend(Some(&database), Some("redis://127.0.0.1/0")), RuntimeBackendArg::Redis ); super::validate_deployment_topology( &args, Some(&database), Some("redis://127.0.0.1/0"), RuntimeBackendArg::Redis, ) .expect("single-node sqlite should allow explicit redis runtime"); } #[test] fn single_node_sqlite_without_redis_allows_memory_runtime_backend() { let args = test_args(); let database = SqlDatabaseConfig::new( DatabaseDriver::Sqlite, "sqlite://./data/aether.db".to_string(), SqlPoolConfig::default(), ) .expect("sqlite config should build"); super::validate_deployment_topology( &args, Some(&database), None, RuntimeBackendArg::Memory, ) .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(); args.deployment_topology = DeploymentTopologyArg::MultiNode; args.node_role = NodeRoleArg::Frontdoor; let database = SqlDatabaseConfig::new( DatabaseDriver::Postgres, "postgres://postgres:postgres@localhost/aether".to_string(), SqlPoolConfig::default(), ) .expect("postgres config should build"); let error = super::validate_deployment_topology( &args, Some(&database), Some("redis://127.0.0.1/0"), RuntimeBackendArg::Memory, ) .expect_err("multi-node memory runtime should be rejected"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!(error.to_string().contains("AETHER_RUNTIME_BACKEND=memory")); } #[test] fn multi_node_rejects_missing_redis_runtime_backend() { let mut args = test_args(); args.deployment_topology = DeploymentTopologyArg::MultiNode; args.node_role = NodeRoleArg::Frontdoor; let database = SqlDatabaseConfig::new( DatabaseDriver::Postgres, "postgres://postgres:postgres@localhost/aether".to_string(), SqlPoolConfig::default(), ) .expect("postgres config should build"); let error = super::validate_deployment_topology( &args, Some(&database), None, RuntimeBackendArg::Redis, ) .expect_err("multi-node should require redis"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!(error.to_string().contains("REDIS_URL")); } #[test] fn multi_node_rejects_sqlite_database_backend() { let mut args = test_args(); args.deployment_topology = DeploymentTopologyArg::MultiNode; args.node_role = NodeRoleArg::Frontdoor; let database = SqlDatabaseConfig::new( DatabaseDriver::Sqlite, "sqlite://./data/aether.db".to_string(), SqlPoolConfig::default(), ) .expect("sqlite config should build"); let error = super::validate_deployment_topology( &args, Some(&database), Some("redis://127.0.0.1/0"), RuntimeBackendArg::Redis, ) .expect_err("multi-node sqlite should be rejected"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!(error.to_string().contains("AETHER_DATABASE_DRIVER=sqlite")); } #[test] fn pending_schema_error_mentions_explicit_migrate_command() { let error = pending_schema_error(2, 20260413020000, "squash usage schema split"); let message = error.to_string(); assert!(message.contains("database schema is behind by 2 migration(s)")); assert!(message.contains("20260413020000")); assert!(message.contains("squash usage schema split")); assert!(message.contains("aether-gateway --migrate")); } #[test] fn pending_backfills_error_mentions_explicit_apply_backfills_command() { let message = pending_backfills_error( 1, 20260422110000, "backfill stats aggregate read path support", ) .to_string(); assert!(message.contains("database backfills are behind by 1 backfill(s)")); assert!(message.contains("20260422110000")); assert!(message.contains("backfill stats aggregate read path support")); assert!(message.contains("aether-gateway --apply-backfills")); assert!(message.contains("before starting the service")); } #[tokio::test] async fn ensure_database_schema_is_current_is_noop_without_database_pool() { let state = AppState::new().expect("state should build"); ensure_database_schema_is_current(&state) .await .expect("disabled data backend should not block startup"); } #[tokio::test] async fn ensure_database_backfills_are_current_is_noop_without_database_pool() { let state = AppState::new().expect("state should build"); ensure_database_backfills_are_current(&state) .await .expect("disabled data backend should not block startup"); } #[tokio::test] async fn auto_prepare_database_is_noop_without_database_pool() { let state = AppState::new().expect("state should build"); super::prepare_database_startup_requirements(&state, true) .await .expect("disabled data backend should not block startup"); } #[tokio::test] async fn explicit_migrate_requires_database_url() { let args = test_args(); let error = super::run_explicit_migrations(&args) .await .expect_err("missing database URL should fail"); let message = error.to_string(); assert!(message.contains("AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL")); assert!(message.contains("--migrate")); } #[tokio::test] async fn explicit_migrate_does_not_depend_on_app_port_validation() { let mut args = test_args(); args.app_port = 0; let error = super::run_explicit_migrations(&args) .await .expect_err("missing database URL should fail before any app port validation"); let message = error.to_string(); assert!(message.contains("AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL")); assert!(!message.contains("APP_PORT")); } #[tokio::test] async fn explicit_backfills_require_database_url() { let args = test_args(); let error = super::run_explicit_backfills(&args) .await .expect_err("missing database URL should fail"); let message = error.to_string(); assert!(message.contains("AETHER_DATABASE_DRIVER/AETHER_DATABASE_URL")); assert!(message.contains("--apply-backfills")); } #[tokio::test] async fn explicit_backfills_are_noop_for_sqlite_database() { let mut args = test_args(); let database_path = std::env::temp_dir().join(format!( "aether-sqlite-backfill-noop-{}-{}.db", std::process::id(), crate::current_unix_secs().expect("clock should be available") )); args.data.database_driver = Some(DatabaseDriverArg::Sqlite); args.data.database_url = Some(format!("sqlite://{}", database_path.display())); super::run_explicit_migrations(&args) .await .expect("sqlite migrations should run before backfills"); super::run_explicit_backfills(&args) .await .expect("sqlite backfills should be an explicit no-op"); let _ = std::fs::remove_file(database_path); } }